From 33846aaffb585da4f4ec114c51f9c81044c041cd Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:09:28 +0100 Subject: [PATCH 01/10] feat(zcode): add ZCode SQLite usage adapter Add a schema-aware, read-only SQLite adapter for completed ZCode model usage, including cache token normalization, root discovery, conservative pricing, focused and unified reports, and CLI, config, and documentation integration. Preserve WAL readers, support legacy and session-versioned layouts, and avoid materializing prompt or content fields. Keep custom providers at zero cost unless an explicit model pricing override is configured. Co-authored-by: axisrow --- apps/ccusage/README.md | 4 +- apps/ccusage/config-schema.json | 678 ++++++++++++++++++ docs/.vitepress/config.ts | 1 + docs/guide/all-reports.md | 4 +- docs/guide/config-files.md | 8 +- docs/guide/configuration.md | 3 +- docs/guide/environment-variables.md | 4 +- docs/guide/getting-started.md | 6 +- docs/guide/index.md | 8 +- docs/guide/zcode/index.md | 116 +++ docs/index.md | 4 +- nix/cargo-artifacts.nix | 1 + rust/Cargo.lock | 14 + rust/Cargo.toml | 1 + rust/adapters/zcode/Cargo.toml | 15 + rust/adapters/zcode/README.md | 90 +++ rust/adapters/zcode/src/lib.rs | 83 +++ rust/adapters/zcode/src/loader.rs | 480 +++++++++++++ rust/adapters/zcode/src/parser.rs | 334 +++++++++ rust/adapters/zcode/src/paths.rs | 214 ++++++ rust/adapters/zcode/src/report.rs | 130 ++++ rust/crates/ccusage-adapter-all/Cargo.toml | 1 + rust/crates/ccusage-adapter-all/README.md | 3 +- rust/crates/ccusage-adapter-all/src/lib.rs | 1 + rust/crates/ccusage-adapter-all/src/loader.rs | 17 +- rust/crates/ccusage-adapter-all/src/report.rs | 1 + rust/crates/ccusage-adapter-all/src/tests.rs | 1 + .../ccusage-cli-parser/src/cli-commands.json | 41 ++ rust/crates/ccusage-cli-parser/src/parser.rs | 15 +- .../ccusage_cli_parser__tests__root_help.snap | 2 + ...shots_representative_cli_parse_shapes.snap | 52 ++ rust/crates/ccusage-cli-parser/src/tests.rs | 17 +- rust/crates/ccusage-cli/src/types.rs | 1 + rust/crates/ccusage-config/src/config.rs | 21 + .../ccusage-config/src/config_schema.rs | 21 +- ...ts_schema_agent_specific_option_edges.snap | 180 ++++- rust/crates/ccusage-core/src/lib.rs | 2 +- rust/crates/ccusage/Cargo.toml | 1 + rust/crates/ccusage/src/adapter/mod.rs | 1 + rust/crates/ccusage/src/cli/last_window.rs | 3 +- rust/crates/ccusage/src/main.rs | 6 +- 41 files changed, 2561 insertions(+), 24 deletions(-) create mode 100644 docs/guide/zcode/index.md create mode 100644 rust/adapters/zcode/Cargo.toml create mode 100644 rust/adapters/zcode/README.md create mode 100644 rust/adapters/zcode/src/lib.rs create mode 100644 rust/adapters/zcode/src/loader.rs create mode 100644 rust/adapters/zcode/src/parser.rs create mode 100644 rust/adapters/zcode/src/paths.rs create mode 100644 rust/adapters/zcode/src/report.rs diff --git a/apps/ccusage/README.md b/apps/ccusage/README.md index 65f7414591..761c6a339f 100644 --- a/apps/ccusage/README.md +++ b/apps/ccusage/README.md @@ -79,6 +79,7 @@ ccusage reads local usage data from coding agent CLIs and turns it into daily, w | GitHub Copilot CLI | `ccusage copilot daily` | | Gemini CLI | `ccusage gemini daily` | | Grok Build CLI | `ccusage grok daily` | +| ZCode | `ccusage zcode daily` | Use `ccusage daily`, `ccusage weekly`, `ccusage monthly`, or `ccusage session` to include every detected source in one report. @@ -135,6 +136,7 @@ bunx ccusage qwen daily bunx ccusage copilot daily bunx ccusage gemini daily bunx ccusage grok daily +bunx ccusage zcode daily bunx ccusage pi daily --pi-path /path/to/sessions bunx ccusage pi daily --pi-path /path/to/sessions,/archive/pi/sessions @@ -168,7 +170,7 @@ bunx ccusage monthly --compact # Compact monthly report - ๐Ÿ“Š **Daily Report**: View token usage and costs aggregated by date - ๐Ÿ“… **Monthly Report**: View token usage and costs aggregated by month - ๐Ÿ’ฌ **Session Report**: View usage grouped by conversation sessions -- ๐Ÿค– **Unified CLI Reports**: View Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, and Grok Build CLI usage from one CLI +- ๐Ÿค– **Unified CLI Reports**: View Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, Grok Build CLI, and ZCode usage from one CLI - โฐ **5-Hour Blocks Report**: Track usage within Claude's billing windows with active block monitoring - ๐Ÿš€ **Statusline Integration**: Compact usage display for Claude Code status bar hooks (Beta) - ๐Ÿค– **Model Tracking**: See which models are used across supported sources diff --git a/apps/ccusage/config-schema.json b/apps/ccusage/config-schema.json index 66d7cbeec8..5c9610a4b0 100644 --- a/apps/ccusage/config-schema.json +++ b/apps/ccusage/config-schema.json @@ -12695,6 +12695,684 @@ } }, "type": "object" + }, + "zcode": { + "additionalProperties": false, + "description": "ZCode configuration.", + "markdownDescription": "ZCode configuration.", + "properties": { + "commands": { + "additionalProperties": false, + "properties": { + "daily": { + "additionalProperties": false, + "properties": { + "all": { + "default": false, + "description": "Accepted for compatibility; all detected supported agents are included by default.", + "markdownDescription": "Accepted for compatibility; all detected supported agents are included by default.", + "type": "boolean" + }, + "breakdown": { + "default": false, + "description": "Show per-model cost breakdown.", + "markdownDescription": "Show per-model cost breakdown.", + "type": "boolean" + }, + "color": { + "default": false, + "description": "Enable colored output.", + "markdownDescription": "Enable colored output.", + "type": "boolean" + }, + "compact": { + "default": false, + "description": "Force compact table layout for narrow terminals.", + "markdownDescription": "Force compact table layout for narrow terminals.", + "type": "boolean" + }, + "debug": { + "default": false, + "description": "Show pricing mismatch information for debugging.", + "markdownDescription": "Show pricing mismatch information for debugging.", + "type": "boolean" + }, + "debugSamples": { + "default": 5, + "description": "Number of sample discrepancies to show in debug output.", + "format": "uint", + "markdownDescription": "Number of sample discrepancies to show in debug output.", + "minimum": 0.0, + "type": "integer" + }, + "jq": { + "description": "jq filter to apply to JSON output.", + "markdownDescription": "jq filter to apply to JSON output.", + "type": "string" + }, + "json": { + "default": false, + "description": "Output in JSON format.", + "markdownDescription": "Output in JSON format.", + "type": "boolean" + }, + "mode": { + "default": "auto", + "description": "Cost calculation mode.", + "enum": ["auto", "calculate", "display"], + "markdownDescription": "Cost calculation mode.", + "type": "string" + }, + "noColor": { + "default": false, + "description": "Disable colored output.", + "markdownDescription": "Disable colored output.", + "type": "boolean" + }, + "noCost": { + "default": false, + "description": "Hide cost information in table and JSON output.", + "markdownDescription": "Hide cost information in table and JSON output.", + "type": "boolean" + }, + "noOffline": { + "default": false, + "description": "Disable cached pricing data where supported.", + "markdownDescription": "Disable cached pricing data where supported.", + "type": "boolean" + }, + "offline": { + "default": false, + "description": "Use cached pricing data where supported.", + "markdownDescription": "Use cached pricing data where supported.", + "type": "boolean" + }, + "order": { + "default": "asc", + "description": "Sort order.", + "enum": ["desc", "asc"], + "markdownDescription": "Sort order.", + "type": "string" + }, + "pricingOverrides": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "cacheCreationInputTokenCost": { + "format": "double", + "type": "number" + }, + "cacheCreationInputTokenCostAbove200kTokens": { + "format": "double", + "type": "number" + }, + "cacheReadInputTokenCost": { + "format": "double", + "type": "number" + }, + "cacheReadInputTokenCostAbove200kTokens": { + "format": "double", + "type": "number" + }, + "fastMultiplier": { + "format": "double", + "type": "number" + }, + "inputCostPerToken": { + "format": "double", + "type": "number" + }, + "inputCostPerTokenAbove200kTokens": { + "format": "double", + "type": "number" + }, + "maxInputTokens": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "outputCostPerToken": { + "format": "double", + "type": "number" + }, + "outputCostPerTokenAbove200kTokens": { + "format": "double", + "type": "number" + } + }, + "type": "object" + }, + "description": "Runtime pricing overrides keyed by raw model name.", + "markdownDescription": "Runtime pricing overrides keyed by raw model name.", + "type": "object" + }, + "since": { + "description": "Filter from date (YYYY-MM-DD or YYYYMMDD).", + "markdownDescription": "Filter from date (YYYY-MM-DD or YYYYMMDD).", + "type": "string" + }, + "singleThread": { + "default": false, + "description": "Disable parallel file processing.", + "markdownDescription": "Disable parallel file processing.", + "type": "boolean" + }, + "timezone": { + "description": "Timezone for date grouping (IANA).", + "markdownDescription": "Timezone for date grouping (IANA).", + "type": "string" + }, + "until": { + "description": "Filter until date (inclusive).", + "markdownDescription": "Filter until date (inclusive).", + "type": "string" + } + }, + "type": "object" + }, + "monthly": { + "additionalProperties": false, + "properties": { + "all": { + "default": false, + "description": "Accepted for compatibility; all detected supported agents are included by default.", + "markdownDescription": "Accepted for compatibility; all detected supported agents are included by default.", + "type": "boolean" + }, + "breakdown": { + "default": false, + "description": "Show per-model cost breakdown.", + "markdownDescription": "Show per-model cost breakdown.", + "type": "boolean" + }, + "color": { + "default": false, + "description": "Enable colored output.", + "markdownDescription": "Enable colored output.", + "type": "boolean" + }, + "compact": { + "default": false, + "description": "Force compact table layout for narrow terminals.", + "markdownDescription": "Force compact table layout for narrow terminals.", + "type": "boolean" + }, + "debug": { + "default": false, + "description": "Show pricing mismatch information for debugging.", + "markdownDescription": "Show pricing mismatch information for debugging.", + "type": "boolean" + }, + "debugSamples": { + "default": 5, + "description": "Number of sample discrepancies to show in debug output.", + "format": "uint", + "markdownDescription": "Number of sample discrepancies to show in debug output.", + "minimum": 0.0, + "type": "integer" + }, + "jq": { + "description": "jq filter to apply to JSON output.", + "markdownDescription": "jq filter to apply to JSON output.", + "type": "string" + }, + "json": { + "default": false, + "description": "Output in JSON format.", + "markdownDescription": "Output in JSON format.", + "type": "boolean" + }, + "mode": { + "default": "auto", + "description": "Cost calculation mode.", + "enum": ["auto", "calculate", "display"], + "markdownDescription": "Cost calculation mode.", + "type": "string" + }, + "noColor": { + "default": false, + "description": "Disable colored output.", + "markdownDescription": "Disable colored output.", + "type": "boolean" + }, + "noCost": { + "default": false, + "description": "Hide cost information in table and JSON output.", + "markdownDescription": "Hide cost information in table and JSON output.", + "type": "boolean" + }, + "noOffline": { + "default": false, + "description": "Disable cached pricing data where supported.", + "markdownDescription": "Disable cached pricing data where supported.", + "type": "boolean" + }, + "offline": { + "default": false, + "description": "Use cached pricing data where supported.", + "markdownDescription": "Use cached pricing data where supported.", + "type": "boolean" + }, + "order": { + "default": "asc", + "description": "Sort order.", + "enum": ["desc", "asc"], + "markdownDescription": "Sort order.", + "type": "string" + }, + "pricingOverrides": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "cacheCreationInputTokenCost": { + "format": "double", + "type": "number" + }, + "cacheCreationInputTokenCostAbove200kTokens": { + "format": "double", + "type": "number" + }, + "cacheReadInputTokenCost": { + "format": "double", + "type": "number" + }, + "cacheReadInputTokenCostAbove200kTokens": { + "format": "double", + "type": "number" + }, + "fastMultiplier": { + "format": "double", + "type": "number" + }, + "inputCostPerToken": { + "format": "double", + "type": "number" + }, + "inputCostPerTokenAbove200kTokens": { + "format": "double", + "type": "number" + }, + "maxInputTokens": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "outputCostPerToken": { + "format": "double", + "type": "number" + }, + "outputCostPerTokenAbove200kTokens": { + "format": "double", + "type": "number" + } + }, + "type": "object" + }, + "description": "Runtime pricing overrides keyed by raw model name.", + "markdownDescription": "Runtime pricing overrides keyed by raw model name.", + "type": "object" + }, + "since": { + "description": "Filter from date (YYYY-MM-DD or YYYYMMDD).", + "markdownDescription": "Filter from date (YYYY-MM-DD or YYYYMMDD).", + "type": "string" + }, + "singleThread": { + "default": false, + "description": "Disable parallel file processing.", + "markdownDescription": "Disable parallel file processing.", + "type": "boolean" + }, + "timezone": { + "description": "Timezone for date grouping (IANA).", + "markdownDescription": "Timezone for date grouping (IANA).", + "type": "string" + }, + "until": { + "description": "Filter until date (inclusive).", + "markdownDescription": "Filter until date (inclusive).", + "type": "string" + } + }, + "type": "object" + }, + "session": { + "additionalProperties": false, + "properties": { + "all": { + "default": false, + "description": "Accepted for compatibility; all detected supported agents are included by default.", + "markdownDescription": "Accepted for compatibility; all detected supported agents are included by default.", + "type": "boolean" + }, + "breakdown": { + "default": false, + "description": "Show per-model cost breakdown.", + "markdownDescription": "Show per-model cost breakdown.", + "type": "boolean" + }, + "color": { + "default": false, + "description": "Enable colored output.", + "markdownDescription": "Enable colored output.", + "type": "boolean" + }, + "compact": { + "default": false, + "description": "Force compact table layout for narrow terminals.", + "markdownDescription": "Force compact table layout for narrow terminals.", + "type": "boolean" + }, + "debug": { + "default": false, + "description": "Show pricing mismatch information for debugging.", + "markdownDescription": "Show pricing mismatch information for debugging.", + "type": "boolean" + }, + "debugSamples": { + "default": 5, + "description": "Number of sample discrepancies to show in debug output.", + "format": "uint", + "markdownDescription": "Number of sample discrepancies to show in debug output.", + "minimum": 0.0, + "type": "integer" + }, + "jq": { + "description": "jq filter to apply to JSON output.", + "markdownDescription": "jq filter to apply to JSON output.", + "type": "string" + }, + "json": { + "default": false, + "description": "Output in JSON format.", + "markdownDescription": "Output in JSON format.", + "type": "boolean" + }, + "mode": { + "default": "auto", + "description": "Cost calculation mode.", + "enum": ["auto", "calculate", "display"], + "markdownDescription": "Cost calculation mode.", + "type": "string" + }, + "noColor": { + "default": false, + "description": "Disable colored output.", + "markdownDescription": "Disable colored output.", + "type": "boolean" + }, + "noCost": { + "default": false, + "description": "Hide cost information in table and JSON output.", + "markdownDescription": "Hide cost information in table and JSON output.", + "type": "boolean" + }, + "noOffline": { + "default": false, + "description": "Disable cached pricing data where supported.", + "markdownDescription": "Disable cached pricing data where supported.", + "type": "boolean" + }, + "offline": { + "default": false, + "description": "Use cached pricing data where supported.", + "markdownDescription": "Use cached pricing data where supported.", + "type": "boolean" + }, + "order": { + "default": "asc", + "description": "Sort order.", + "enum": ["desc", "asc"], + "markdownDescription": "Sort order.", + "type": "string" + }, + "pricingOverrides": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "cacheCreationInputTokenCost": { + "format": "double", + "type": "number" + }, + "cacheCreationInputTokenCostAbove200kTokens": { + "format": "double", + "type": "number" + }, + "cacheReadInputTokenCost": { + "format": "double", + "type": "number" + }, + "cacheReadInputTokenCostAbove200kTokens": { + "format": "double", + "type": "number" + }, + "fastMultiplier": { + "format": "double", + "type": "number" + }, + "inputCostPerToken": { + "format": "double", + "type": "number" + }, + "inputCostPerTokenAbove200kTokens": { + "format": "double", + "type": "number" + }, + "maxInputTokens": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "outputCostPerToken": { + "format": "double", + "type": "number" + }, + "outputCostPerTokenAbove200kTokens": { + "format": "double", + "type": "number" + } + }, + "type": "object" + }, + "description": "Runtime pricing overrides keyed by raw model name.", + "markdownDescription": "Runtime pricing overrides keyed by raw model name.", + "type": "object" + }, + "since": { + "description": "Filter from date (YYYY-MM-DD or YYYYMMDD).", + "markdownDescription": "Filter from date (YYYY-MM-DD or YYYYMMDD).", + "type": "string" + }, + "singleThread": { + "default": false, + "description": "Disable parallel file processing.", + "markdownDescription": "Disable parallel file processing.", + "type": "boolean" + }, + "timezone": { + "description": "Timezone for date grouping (IANA).", + "markdownDescription": "Timezone for date grouping (IANA).", + "type": "string" + }, + "until": { + "description": "Filter until date (inclusive).", + "markdownDescription": "Filter until date (inclusive).", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "defaults": { + "additionalProperties": false, + "properties": { + "all": { + "default": false, + "description": "Accepted for compatibility; all detected supported agents are included by default.", + "markdownDescription": "Accepted for compatibility; all detected supported agents are included by default.", + "type": "boolean" + }, + "breakdown": { + "default": false, + "description": "Show per-model cost breakdown.", + "markdownDescription": "Show per-model cost breakdown.", + "type": "boolean" + }, + "color": { + "default": false, + "description": "Enable colored output.", + "markdownDescription": "Enable colored output.", + "type": "boolean" + }, + "compact": { + "default": false, + "description": "Force compact table layout for narrow terminals.", + "markdownDescription": "Force compact table layout for narrow terminals.", + "type": "boolean" + }, + "debug": { + "default": false, + "description": "Show pricing mismatch information for debugging.", + "markdownDescription": "Show pricing mismatch information for debugging.", + "type": "boolean" + }, + "debugSamples": { + "default": 5, + "description": "Number of sample discrepancies to show in debug output.", + "format": "uint", + "markdownDescription": "Number of sample discrepancies to show in debug output.", + "minimum": 0.0, + "type": "integer" + }, + "jq": { + "description": "jq filter to apply to JSON output.", + "markdownDescription": "jq filter to apply to JSON output.", + "type": "string" + }, + "json": { + "default": false, + "description": "Output in JSON format.", + "markdownDescription": "Output in JSON format.", + "type": "boolean" + }, + "mode": { + "default": "auto", + "description": "Cost calculation mode.", + "enum": ["auto", "calculate", "display"], + "markdownDescription": "Cost calculation mode.", + "type": "string" + }, + "noColor": { + "default": false, + "description": "Disable colored output.", + "markdownDescription": "Disable colored output.", + "type": "boolean" + }, + "noCost": { + "default": false, + "description": "Hide cost information in table and JSON output.", + "markdownDescription": "Hide cost information in table and JSON output.", + "type": "boolean" + }, + "noOffline": { + "default": false, + "description": "Disable cached pricing data where supported.", + "markdownDescription": "Disable cached pricing data where supported.", + "type": "boolean" + }, + "offline": { + "default": false, + "description": "Use cached pricing data where supported.", + "markdownDescription": "Use cached pricing data where supported.", + "type": "boolean" + }, + "order": { + "default": "asc", + "description": "Sort order.", + "enum": ["desc", "asc"], + "markdownDescription": "Sort order.", + "type": "string" + }, + "pricingOverrides": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "cacheCreationInputTokenCost": { + "format": "double", + "type": "number" + }, + "cacheCreationInputTokenCostAbove200kTokens": { + "format": "double", + "type": "number" + }, + "cacheReadInputTokenCost": { + "format": "double", + "type": "number" + }, + "cacheReadInputTokenCostAbove200kTokens": { + "format": "double", + "type": "number" + }, + "fastMultiplier": { + "format": "double", + "type": "number" + }, + "inputCostPerToken": { + "format": "double", + "type": "number" + }, + "inputCostPerTokenAbove200kTokens": { + "format": "double", + "type": "number" + }, + "maxInputTokens": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "outputCostPerToken": { + "format": "double", + "type": "number" + }, + "outputCostPerTokenAbove200kTokens": { + "format": "double", + "type": "number" + } + }, + "type": "object" + }, + "description": "Runtime pricing overrides keyed by raw model name.", + "markdownDescription": "Runtime pricing overrides keyed by raw model name.", + "type": "object" + }, + "since": { + "description": "Filter from date (YYYY-MM-DD or YYYYMMDD).", + "markdownDescription": "Filter from date (YYYY-MM-DD or YYYYMMDD).", + "type": "string" + }, + "singleThread": { + "default": false, + "description": "Disable parallel file processing.", + "markdownDescription": "Disable parallel file processing.", + "type": "boolean" + }, + "timezone": { + "description": "Timezone for date grouping (IANA).", + "markdownDescription": "Timezone for date grouping (IANA).", + "type": "string" + }, + "until": { + "description": "Filter until date (inclusive).", + "markdownDescription": "Filter until date (inclusive).", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" } }, "type": "object" diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 909cd0987f..9d83b50854 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -84,6 +84,7 @@ export default defineConfig({ { text: 'Kimi', link: '/guide/kimi/' }, { text: 'OpenClaw', link: '/guide/openclaw/' }, { text: 'Grok Build CLI', link: '/guide/grok/' }, + { text: 'ZCode', link: '/guide/zcode/' }, { text: 'Source Support Q&A', link: '/guide/source-support-qa' }, ], }, diff --git a/docs/guide/all-reports.md b/docs/guide/all-reports.md index 27aa8f0445..704f0108d7 100644 --- a/docs/guide/all-reports.md +++ b/docs/guide/all-reports.md @@ -35,7 +35,7 @@ ccusage daily --by-source --json ## How Unified Views Work -ccusage detects local usage files from Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, and Grok Build CLI. The same daily, weekly, monthly, and session views can run in two modes: +ccusage detects local usage files from Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, Grok Build CLI, and ZCode. The same daily, weekly, monthly, and session views can run in two modes: | Mode | Command example | What it shows | | ------- | ---------------------- | --------------------------------------- | @@ -65,6 +65,7 @@ Unified tables include an **Agent** column so you can compare sources in one vie | Copilot CLI | `copilot` | `ccusage copilot daily` | | Gemini CLI | `gemini` | `ccusage gemini daily` | | Grok Build CLI | `grok` | `ccusage grok daily` | +| ZCode | `zcode` | `ccusage zcode daily` | ## When to Focus a Source @@ -84,6 +85,7 @@ ccusage qwen daily ccusage copilot daily --json ccusage gemini session --json ccusage grok daily --json +ccusage zcode daily --json ``` ## Next Steps diff --git a/docs/guide/config-files.md b/docs/guide/config-files.md index 7215e9c4bd..517698365b 100644 --- a/docs/guide/config-files.md +++ b/docs/guide/config-files.md @@ -180,7 +180,7 @@ Override shared defaults for specific unified reports and legacy Claude commands ### Source-Specific Configuration -Use data source namespaces to set defaults and report overrides. Supported namespaces are `claude`, `codex`, `opencode`, `amp`, `droid`, `codebuff`, `hermes`, `pi`, `goose`, `openclaw`, `kilo`, `kimi`, `qwen`, `copilot`, `gemini`, and `grok`. +Use data source namespaces to set defaults and report overrides. Supported namespaces are `claude`, `codex`, `opencode`, `amp`, `droid`, `codebuff`, `hermes`, `pi`, `goose`, `openclaw`, `kilo`, `kimi`, `qwen`, `copilot`, `gemini`, `grok`, and `zcode`. ```json { @@ -261,6 +261,11 @@ Use data source namespaces to set defaults and report overrides. Supported names "defaults": { "offline": true } + }, + "zcode": { + "defaults": { + "offline": true + } } } ``` @@ -279,6 +284,7 @@ ccusage kimi daily ccusage qwen daily ccusage copilot monthly ccusage gemini daily +ccusage zcode daily ``` Source-specific report settings apply only to the focused commands shown above. In particular, `codex.defaults.bySource` is honored only by focused Codex commands. Unified reports such as `ccusage daily` do not apply agent-specific options; use the unified `--by-source` flag to request Codex client/originator breakdowns there. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 45130528e3..2297fe2c96 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -56,6 +56,7 @@ export AMP_DATA_DIR="$HOME/.local/share/amp" export PI_AGENT_DIR="$HOME/.pi/agent/sessions" export KILO_DATA_DIR="$HOME/.local/share/kilo" export COPILOT_OTEL_FILE_EXPORTER_PATH="$HOME/.copilot/otel/copilot-otel.jsonl" +export ZCODE_HOME="$HOME/.zcode" ``` Use comma-separated directories when you want reports to combine multiple profiles or archives: @@ -109,7 +110,7 @@ For individual developers working on multiple projects: ### Multiple Sources -Configure Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, and Gemini CLI separately with data source namespaces: +Configure Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, Grok Build CLI, and ZCode separately with data source namespaces: ```json // ~/.config/claude/ccusage.json diff --git a/docs/guide/environment-variables.md b/docs/guide/environment-variables.md index ace58ecd12..ef115352cd 100644 --- a/docs/guide/environment-variables.md +++ b/docs/guide/environment-variables.md @@ -24,6 +24,7 @@ ccusage detects supported data source files from conventional locations by defau | `COPILOT_OTEL_FILE_EXPORTER_PATH` | Copilot CLI | Explicit `.jsonl` file | | `GEMINI_DATA_DIR` | Gemini CLI | `~/.gemini/tmp` | | `GROK_HOME` | Grok Build CLI | `~/.grok` | +| `ZCODE_HOME` | ZCode | `~/.zcode` | Example: @@ -43,10 +44,11 @@ export QWEN_DATA_DIR="/path/to/qwen,/archive/qwen" export COPILOT_OTEL_FILE_EXPORTER_PATH="/path/to/copilot-otel.jsonl" export GEMINI_DATA_DIR="/path/to/gemini/tmp,/archive/gemini/tmp" export GROK_HOME="/path/to/grok-home" +export ZCODE_HOME="/path/to/zcode-home,/archive/zcode-home" ccusage daily ``` -Empty entries, directories that do not exist, and missing explicit files are skipped. Duplicate paths are read once. +Empty entries, directories that do not exist, and missing explicit files are skipped. Duplicate paths are read once. A non-empty `ZCODE_HOME` containing only invalid roots does not fall back to `~/.zcode`. ## CLAUDE_CONFIG_DIR diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index c9799ccb34..03ab059d64 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -115,6 +115,7 @@ ccusage daily --since 2026-05-01 --until 2026-05-16 ```bash ccusage codex daily ccusage claude monthly +ccusage zcode daily ``` ### Use Source-Specific Options @@ -177,6 +178,7 @@ If ccusage shows no data, check: - Qwen: `${QWEN_DATA_DIR:-~/.qwen}` - GitHub Copilot CLI: `~/.copilot/otel/*.jsonl` or `COPILOT_OTEL_FILE_EXPORTER_PATH` - Grok Build CLI: `${GROK_HOME:-~/.grok}` + - ZCode: `${ZCODE_HOME:-~/.zcode}/cli/db/db.sqlite` ### Custom Data Directory @@ -198,9 +200,10 @@ export KIMI_DATA_DIR="/path/to/kimi" export QWEN_DATA_DIR="/path/to/qwen" export COPILOT_OTEL_FILE_EXPORTER_PATH="/path/to/copilot-otel.jsonl" export GROK_HOME="/path/to/grok-home" +export ZCODE_HOME="/path/to/zcode-home" ``` -Each source-specific path variable can also contain comma-separated directories, except `GROK_HOME`, which takes a single root: +Each source-specific path variable can also contain comma-separated directories, except `GROK_HOME`, which takes a single root. `ZCODE_HOME` accepts multiple roots and deduplicates them: ```bash export CODEX_HOME="/path/to/codex,/archive/codex,/path/to/codex-exec-jsonl" @@ -215,6 +218,7 @@ export OPENCLAW_DIR="/path/to/openclaw,/archive/openclaw" export KILO_DATA_DIR="/path/to/kilo,/archive/kilo" export KIMI_DATA_DIR="/path/to/kimi,/archive/kimi" export QWEN_DATA_DIR="/path/to/qwen,/archive/qwen" +export ZCODE_HOME="/path/to/zcode,/archive/zcode" ``` ## Getting Help diff --git a/docs/guide/index.md b/docs/guide/index.md index b2c8c52b9d..1cb8e2b577 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -2,9 +2,9 @@ ![ccusage daily report showing token usage and costs by date](/screenshot.png) -**ccusage** is a local CLI for understanding coding (agent) CLI token usage and estimated costs across Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, and Grok Build CLI. +**ccusage** is a local CLI for understanding coding (agent) CLI token usage and estimated costs across Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, Grok Build CLI, and ZCode. -The original **โ€œccโ€** came from **C**laude **C**ode usage and now also fits **C**odex **C**LI usage. As OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, Gemini CLI, Grok Build CLI, and other coding (agent) CLIs became part of the same workflow, ccusage expanded into a general name for local coding CLI usage analysis. +The original **โ€œccโ€** came from **C**laude **C**ode usage and now also fits **C**odex **C**LI usage. As OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, Gemini CLI, Grok Build CLI, ZCode, and other coding (agent) CLIs became part of the same workflow, ccusage expanded into a general name for local coding CLI usage analysis. ## The Problem @@ -19,7 +19,7 @@ Modern coding (agent) CLI usage is split across several local data formats. That ccusage reads the local usage files that coding CLIs already generate and provides: -- **All Sources by Default** - Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, and Grok Build CLI in one CLI +- **All Sources by Default** - Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, Grok Build CLI, and ZCode in one CLI - **Usage Views** - Daily, weekly, monthly, and session-based breakdowns - **Cost Analysis** - Estimated costs based on token usage and model pricing - **Focused Data Source Views** - Start with all detected sources, then narrow the same usage views to one source when needed @@ -90,6 +90,7 @@ ccusage reads from local coding CLI data directories: | Copilot CLI | `copilot` | `~/.copilot/otel/*.jsonl` | | Gemini CLI | `gemini` | `${GEMINI_DATA_DIR:-~/.gemini/tmp}` | | Grok Build CLI | `grok` | `${GROK_HOME:-~/.grok}` | +| ZCode | `zcode` | `${ZCODE_HOME:-~/.zcode}` | For OpenCode, `${XDG_DATA_HOME:-$HOME/.local/share}/opencode` is the default-path fallback. When set, `OPENCODE_DATA_DIR` overrides that path; an explicitly empty value disables fallback discovery. @@ -128,6 +129,7 @@ ccusage qwen daily ccusage copilot daily ccusage gemini daily ccusage grok daily +ccusage zcode daily ``` Use `ccusage ` only when you want to narrow a report to one source. diff --git a/docs/guide/zcode/index.md b/docs/guide/zcode/index.md new file mode 100644 index 0000000000..bbcb28e102 --- /dev/null +++ b/docs/guide/zcode/index.md @@ -0,0 +1,116 @@ +# ZCode Data Source + +ccusage reads completed ZCode model-usage records from the local SQLite +database and exposes the same focused and unified reports as the other +supported sources. + +## Focused Views + +```bash +ccusage zcode daily +ccusage zcode monthly +ccusage zcode session +``` + +Use `ccusage daily`, `ccusage monthly`, or `ccusage session` when ZCode should +be included with every other detected source. + +## Data Source + +ZCode stores its usage ledger at `cli/db/db.sqlite` beneath its data root: + +```text +$ZCODE_HOME/ # or ~/.zcode +โ””โ”€โ”€ cli/ + โ””โ”€โ”€ db/ + โ””โ”€โ”€ db.sqlite +``` + +Set `ZCODE_HOME` to one root or a comma-separated list of roots when usage data +is stored elsewhere. Roots and database paths are canonicalized and +deduplicated. When `ZCODE_HOME` is non-empty, invalid configured roots are +skipped and discovery does not fall back to `~/.zcode`. + +The adapter validates the `model_usage` and `session` tables before reading +them. The required columns are `id`, `session_id`, `started_at`, `model_id`, +`status`, `input_tokens`, and `output_tokens` on `model_usage`, plus `id` and +`directory` on `session`. Optional cache, provider, computed-total, and session +version columns are detected individually. The presence of `session.version` +is reported as the session-versioned layout; older databases without it use +the legacy layout. Compatible additive schema changes are accepted, while +incomplete or unrelated databases are ignored. + +Only rows whose status is exactly `completed` are counted. `started_at` is an +epoch-millisecond timestamp, so report dates retain sub-second precision in +the source entry. The read query selects only identifiers, timestamps, status, +token counters, model/provider metadata, and the session directory. Prompt, +content, and raw usage fields are not materialized. + +Connections are opened read-only, use a busy timeout while ZCode may be +writing, and enable connection-local SQLite query-only mode. The adapter does +not change journal mode, so an existing ZCode WAL remains available for +concurrent reads. + +## Token and Cost Handling + +ZCode's `input_tokens` includes both cache-read and cache-creation tokens. The +adapter subtracts both buckets from fresh input and reports them separately. +Cache buckets are bounded by the inclusive input count, and any positive +remainder in `computed_total_tokens` is preserved by the shared extra-token +fallback. + +ZCode does not record a per-request cost. `--mode display` therefore reports +zero cost. `--mode calculate` and `auto` use the shared model pricing map when +the model can be resolved. Built-in Z.ai pricing is selected for an explicit +Z.ai provider, or for legacy rows with no provider and a GLM model. A custom +provider UUID is not treated as a billable provider even when its model name +matches a generic pricing entry; use an explicit model pricing override when +that provider should be estimated. Unpriced models remain at zero cost and use +the standard missing-pricing warning. + +## Configuration + +The `zcode` namespace supports the shared report options: + +```json +{ + "zcode": { + "defaults": { + "offline": true + }, + "commands": { + "daily": { + "json": true + } + } + } +} +``` + +The data root is controlled by `ZCODE_HOME`; it is not a billing or provider +configuration setting. + +## Report Views + +| Focused view | Description | See also | +| ----------------------- | ---------------------------- | --------------------------------------- | +| `ccusage zcode daily` | Aggregate usage by date | [Daily Usage](/guide/daily-reports) | +| `ccusage zcode monthly` | Aggregate usage by month | [Monthly Usage](/guide/monthly-reports) | +| `ccusage zcode session` | Group usage by ZCode session | [Session Usage](/guide/session-reports) | + +These views support the standard `--json`, `--compact`, `--mode`, `--offline`, +`--since`, `--until`, and `--timezone` options. + +## Troubleshooting + +::: details No ZCode usage data found +Ensure `$ZCODE_HOME/cli/db/db.sqlite` or `~/.zcode/cli/db/db.sqlite` exists and contains the `model_usage` and `session` tables. Use `--debug` to see rejected roots, schema diagnostics, and database read errors. +::: + +::: details Costs showing as $0.00 +ZCode has no per-request cost field. Use `--mode calculate` to estimate rows whose model is in the pricing map. Custom provider UUIDs do not identify a billable provider, so unpriced custom models remain at zero. +::: + +::: details Totals lower than expected while ZCode is open +Only completed model-usage rows are included. Wait for the turn to complete and rerun the report. +::: diff --git a/docs/index.md b/docs/index.md index 8603ff7f34..cab55d1e1e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ layout: home hero: name: ccusage text: Coding (Agent) CLI Usage Analysis - tagline: A fast local CLI for tracking tokens and estimated costs across Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, and Gemini CLI + tagline: A fast local CLI for tracking tokens and estimated costs across Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, Grok Build CLI, and ZCode image: src: /logo.svg alt: ccusage logo @@ -27,7 +27,7 @@ features: link: /guide/getting-started - icon: ๐Ÿ“ title: Local Data Sources - details: Reads local usage logs from Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, and Gemini CLI without uploading your data + details: Reads local usage logs from Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, Grok Build CLI, and ZCode without uploading your data link: /guide/ - icon: ๐Ÿ’ฐ title: Cost Analysis diff --git a/nix/cargo-artifacts.nix b/nix/cargo-artifacts.nix index 32466d99d9..58599becf6 100644 --- a/nix/cargo-artifacts.nix +++ b/nix/cargo-artifacts.nix @@ -48,6 +48,7 @@ let "opencode" "pi" "qwen" + "zcode" ]; adapterCrates = map (name: "ccusage-adapter-${name}") agentNames ++ [ "ccusage-adapter-all" ]; diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 174ed74eb7..7a33e2b9f6 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -137,6 +137,7 @@ dependencies = [ "ccusage-adapter-opencode", "ccusage-adapter-pi", "ccusage-adapter-qwen", + "ccusage-adapter-zcode", "ccusage-cli", "ccusage-cli-parser", "ccusage-config", @@ -169,6 +170,7 @@ dependencies = [ "ccusage-adapter-opencode", "ccusage-adapter-pi", "ccusage-adapter-qwen", + "ccusage-adapter-zcode", "ccusage-cli", "ccusage-core", "ccusage-test-support", @@ -388,6 +390,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "ccusage-adapter-zcode" +version = "0.0.0" +dependencies = [ + "ccusage-adapter-common", + "ccusage-core", + "ccusage-test-support", + "jiff", + "serde_json", + "sqlite", +] + [[package]] name = "ccusage-cli" version = "0.0.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 871f287186..b606e151e3 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -34,6 +34,7 @@ ccusage-adapter-openclaw = { path = "adapters/openclaw" } ccusage-adapter-opencode = { path = "adapters/opencode" } ccusage-adapter-pi = { path = "adapters/pi" } ccusage-adapter-qwen = { path = "adapters/qwen" } +ccusage-adapter-zcode = { path = "adapters/zcode" } ccusage-cli = { path = "crates/ccusage-cli" } ccusage-cli-parser = { path = "crates/ccusage-cli-parser" } ccusage-config = { path = "crates/ccusage-config" } diff --git a/rust/adapters/zcode/Cargo.toml b/rust/adapters/zcode/Cargo.toml new file mode 100644 index 0000000000..bdb30d9d51 --- /dev/null +++ b/rust/adapters/zcode/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ccusage-adapter-zcode" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +ccusage-adapter-common.workspace = true +ccusage-core.workspace = true +jiff.workspace = true +serde_json.workspace = true +sqlite.workspace = true + +[dev-dependencies] +ccusage-test-support.workspace = true diff --git a/rust/adapters/zcode/README.md b/rust/adapters/zcode/README.md new file mode 100644 index 0000000000..45a00f0dfb --- /dev/null +++ b/rust/adapters/zcode/README.md @@ -0,0 +1,90 @@ +# ccusage-adapter-zcode + +The ZCode adapter reads completed model-usage records from the ZCode desktop +application's local SQLite ledger and turns them into the entries used by +focused and unified reports. + +## Owns + +- `loader.rs` โ€” read-only SQLite access, schema checks, completed-row filtering, and deduplication +- `parser.rs` โ€” token normalization, model selection, and cost-mode behavior +- `paths.rs` โ€” `ZCODE_HOME` root resolution and database discovery +- `report.rs` โ€” daily, monthly, weekly, and session summary shapes + +Source-specific behavior stays here; shared report and pricing behavior belongs +in `ccusage-core` or `ccusage-adapter-common`. + +## Data source + +```text +$ZCODE_HOME/ # or ~/.zcode +โ””โ”€โ”€ cli/ + โ””โ”€โ”€ db/ + โ””โ”€โ”€ db.sqlite +``` + +The adapter reads `model_usage` joined to `session`. It selects identifiers, +timestamps, status, token counters, provider/model metadata, and the session +directory only. Prompt, content, rollout, and raw usage fields are not read. + +The supported schema requires `model_usage.id`, `session_id`, `started_at`, +`model_id`, `status`, `input_tokens`, and `output_tokens`, plus +`session.id` and `session.directory`. `cache_creation_input_tokens`, +`cache_read_input_tokens`, `computed_total_tokens`, `provider_id`, and +`session.version` are read when present. The presence of `session.version` is +reported as the session-versioned layout in debug diagnostics; compatible +future versions are accepted when the required columns remain available, while +unrelated or incomplete schemas are skipped. + +Connections use SQLite read-only mode, a busy timeout, and connection-local +query-only mode. The adapter does not change journal mode, so an existing +ZCode WAL remains readable while the application is running. + +A non-empty `ZCODE_HOME` may contain comma-separated roots. Roots are +canonicalized and deduplicated; invalid configured roots are reported with +`--debug` and do not silently fall back to `~/.zcode`. Databases are also +deduplicated, and duplicate usage ids keep the first configured root's row. + +Only rows with `status = 'completed'` are included. `started_at` is interpreted +as an epoch-millisecond timestamp and retains millisecond precision. Daily, +monthly, weekly, and session summaries use the standard ccusage report shapes. + +## Token and cost mapping + +ZCode's `input_tokens` includes the cache-read and cache-creation slices. The +adapter subtracts both from fresh input and reports each cache bucket +separately. Negative counters are clamped to zero, and cache buckets are +bounded by the inclusive input count. Any positive remainder in +`computed_total_tokens` is preserved through the shared extra-token fallback. + +ZCode records no per-request cost. `display` therefore reports zero cost; +`calculate` and `auto` estimate from the shared pricing map. Raw model pricing +overrides are honored. Z.ai-qualified candidates are used for explicit Z.ai +providers and for GLM model ids from legacy rows without a provider; an unknown +custom provider is not treated as Z.ai. +Unknown custom model ids remain at zero and use the normal missing-pricing +warning. No provider billing is inferred from a local provider UUID. + +## Public surface + +- `loader::load_entries` +- `report::summarize_entries` +- `run` + +## Depends on + +- `ccusage-adapter-common` +- `ccusage-core` +- `jiff` +- `serde_json` +- `sqlite` + +## Testing + +```text +cargo test -p ccusage-adapter-zcode +``` + +The fixture tests cover cache normalization, timestamp precision, completed +row filtering, legacy and incompatible schemas, optional metadata, root and +database deduplication, custom-provider pricing, and focused report shapes. diff --git a/rust/adapters/zcode/src/lib.rs b/rust/adapters/zcode/src/lib.rs new file mode 100644 index 0000000000..d8ec8dfb72 --- /dev/null +++ b/rust/adapters/zcode/src/lib.rs @@ -0,0 +1,83 @@ +use ccusage_adapter_common::filter_loaded_entries_by_date; +use ccusage_core::*; + +mod loader; +mod parser; +mod paths; +mod report; + +use crate::{ + PricingMap, Result, UsageTableOptions, cli::AgentCommandArgs, print_json_or_jq, + print_usage_table_with_options, sort_summaries, wants_json, +}; + +pub use loader::load_entries; +pub(crate) use report::report_from_rows; +pub use report::summarize_entries; + +pub fn run(args: AgentCommandArgs) -> Result<()> { + let shared = args.shared; + let pricing = PricingMap::load_with_overrides( + shared.offline, + crate::log_level() != Some(0), + shared.pricing_overrides.iter(), + ); + let mut entries = load_entries(&shared, &pricing)?; + filter_loaded_entries_by_date(&mut entries, &shared); + let mut rows = summarize_entries(&entries, args.kind)?; + sort_summaries(&mut rows, &shared.order, |row| { + ccusage_core::summary_period(row) + }); + if wants_json(&shared) { + return print_json_or_jq( + report_from_rows(&rows, args.kind), + shared.jq.as_deref(), + shared.no_cost, + ); + } + let table_options = UsageTableOptions { + show_cache_creation: rows.iter().any(|row| row.cache_creation_tokens > 0), + }; + print_usage_table_with_options( + "ZCode Token Usage Report", + ccusage_core::first_column(args.kind), + &rows, + &shared, + false, + None, + table_options, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exposes_the_standard_cache_creation_table_option() { + let rows = [UsageSummary { + date: Some("2026-08-16".to_string()), + month: None, + week: None, + session_id: None, + project_path: None, + last_activity: None, + first_activity: None, + input_tokens: 1, + output_tokens: 1, + cache_creation_tokens: 2, + cache_read_tokens: 3, + extra_total_tokens: 0, + total_cost: 0.0, + credits: None, + message_count: None, + models_used: vec!["GLM-5.3".to_string()], + model_breakdowns: Vec::new(), + project: None, + versions: None, + }]; + + assert!(rows.iter().any(|row| row.cache_creation_tokens > 0)); + } +} diff --git a/rust/adapters/zcode/src/loader.rs b/rust/adapters/zcode/src/loader.rs new file mode 100644 index 0000000000..f2a0413144 --- /dev/null +++ b/rust/adapters/zcode/src/loader.rs @@ -0,0 +1,480 @@ +use std::{collections::HashSet, path::Path}; + +use jiff::tz::TimeZone as JiffTimeZone; + +use ccusage_adapter_common::read_files_parallel; + +use crate::{LoadedEntry, PricingMap, Result, cli::SharedArgs, debug_log, parse_tz}; + +use super::{ + parser::{ZcodeUsageRow, row_to_entry}, + paths::db_paths, +}; + +const REQUIRED_MODEL_COLUMNS: &[&str] = &[ + "id", + "session_id", + "started_at", + "model_id", + "status", + "input_tokens", + "output_tokens", +]; +const REQUIRED_SESSION_COLUMNS: &[&str] = &["id", "directory"]; + +#[derive(Debug, Clone, Copy)] +struct Schema { + version: SchemaVersion, + has_provider_id: bool, + has_cache_creation: bool, + has_cache_read: bool, + has_computed_total: bool, + has_session_version: bool, +} + +#[derive(Debug, Clone, Copy)] +enum SchemaVersion { + Legacy, + SessionVersioned, +} + +impl SchemaVersion { + fn label(self) -> &'static str { + match self { + Self::Legacy => "legacy", + Self::SessionVersioned => "session-versioned", + } + } +} + +pub fn load_entries(shared: &SharedArgs, pricing: &PricingMap) -> Result> { + crate::progress::track_usage_load( + crate::progress::UsageLoadAgent("ZCode"), + shared.json, + || load_entries_inner(shared, pricing), + ) +} + +fn load_entries_inner(shared: &SharedArgs, pricing: &PricingMap) -> Result> { + let tz = parse_tz(shared.timezone.as_deref()); + let db_paths = db_paths(shared)?; + let loaded = read_files_parallel(&db_paths, shared.single_thread, |db_path| { + load_entries_from_database(db_path, tz.as_ref(), shared, pricing) + }); + + let mut entries = Vec::new(); + let mut seen = HashSet::new(); + for db_entries in loaded { + for entry in db_entries { + let Some(id) = entry.data.message.id.as_deref() else { + entries.push(entry); + continue; + }; + if seen.insert(id.to_string()) { + entries.push(entry); + } + } + } + entries.sort_by_key(|entry| entry.timestamp); + Ok(entries) +} + +fn load_entries_from_database( + db_path: &Path, + tz: Option<&JiffTimeZone>, + shared: &SharedArgs, + pricing: &PricingMap, +) -> Vec { + let Ok(connection) = + sqlite::Connection::open_with_flags(db_path, sqlite::OpenFlags::new().with_read_only()) + else { + debug_log( + shared, + format!("Failed to open ZCode database: {}", db_path.display()), + ); + return Vec::new(); + }; + + if let Err(error) = connection.execute("PRAGMA busy_timeout = 5000") { + debug_log( + shared, + format!("Failed to configure ZCode database locking: {error}"), + ); + return Vec::new(); + } + if let Err(error) = connection.execute("PRAGMA query_only = ON") { + debug_log( + shared, + format!("Failed to configure ZCode read-only mode: {error}"), + ); + return Vec::new(); + } + + let Some(schema) = read_schema(&connection, db_path, shared) else { + return Vec::new(); + }; + debug_log( + shared, + format!( + "Reading ZCode SQLite schema at {} (layout={}, cache_creation={}, cache_read={}, computed_total={}, provider_id={}, session_version={})", + db_path.display(), + schema.version.label(), + schema.has_cache_creation, + schema.has_cache_read, + schema.has_computed_total, + schema.has_provider_id, + schema.has_session_version, + ), + ); + + let cache_creation = if schema.has_cache_creation { + "m.cache_creation_input_tokens" + } else { + "0" + }; + let cache_read = if schema.has_cache_read { + "m.cache_read_input_tokens" + } else { + "0" + }; + let computed_total = if schema.has_computed_total { + "m.computed_total_tokens" + } else { + "m.input_tokens + m.output_tokens" + }; + let provider_id = if schema.has_provider_id { + "CAST(m.provider_id AS TEXT)" + } else { + "NULL" + }; + let version = if schema.has_session_version { + "CAST(s.version AS TEXT)" + } else { + "NULL" + }; + let query = format!( + "SELECT m.id, m.session_id, m.started_at, m.model_id, m.input_tokens, m.output_tokens, {cache_creation}, {cache_read}, {computed_total}, {provider_id}, s.directory, {version} FROM model_usage m LEFT JOIN session s ON s.id = m.session_id WHERE m.status = 'completed'" + ); + let Ok(mut statement) = connection.prepare(&query) else { + debug_log( + shared, + format!("Failed to query ZCode database: {}", db_path.display()), + ); + return Vec::new(); + }; + + let mut entries = Vec::new(); + loop { + match statement.next() { + Ok(sqlite::State::Row) => { + if let Some(entry) = read_usage_row(&statement).and_then(|row| { + row_to_entry(row, tz, shared.mode, pricing, &shared.pricing_overrides) + }) { + entries.push(entry); + } + } + Ok(sqlite::State::Done) => break, + Err(error) => { + debug_log( + shared, + format!( + "Failed to read ZCode database {}: {error}", + db_path.display() + ), + ); + break; + } + } + } + entries +} + +fn read_schema( + connection: &sqlite::Connection, + db_path: &Path, + shared: &SharedArgs, +) -> Option { + let model_columns = match table_columns(connection, "model_usage") { + Ok(columns) => columns, + Err(error) => { + debug_log( + shared, + format!( + "Failed to inspect ZCode model_usage schema at {}: {error}", + db_path.display() + ), + ); + return None; + } + }; + let session_columns = match table_columns(connection, "session") { + Ok(columns) => columns, + Err(error) => { + debug_log( + shared, + format!( + "Failed to inspect ZCode session schema at {}: {error}", + db_path.display() + ), + ); + return None; + } + }; + let missing_model = missing_columns(&model_columns, REQUIRED_MODEL_COLUMNS); + let missing_session = missing_columns(&session_columns, REQUIRED_SESSION_COLUMNS); + if !missing_model.is_empty() || !missing_session.is_empty() { + let mut details = Vec::new(); + if !missing_model.is_empty() { + details.push(format!("model_usage: {}", missing_model.join(", "))); + } + if !missing_session.is_empty() { + details.push(format!("session: {}", missing_session.join(", "))); + } + debug_log( + shared, + format!( + "Unsupported ZCode SQLite schema at {}: missing {}", + db_path.display(), + details.join("; ") + ), + ); + return None; + } + + Some(Schema { + version: if session_columns.contains("version") { + SchemaVersion::SessionVersioned + } else { + SchemaVersion::Legacy + }, + has_provider_id: model_columns.contains("provider_id"), + has_cache_creation: model_columns.contains("cache_creation_input_tokens"), + has_cache_read: model_columns.contains("cache_read_input_tokens"), + has_computed_total: model_columns.contains("computed_total_tokens"), + has_session_version: session_columns.contains("version"), + }) +} + +fn table_columns(connection: &sqlite::Connection, table: &str) -> sqlite::Result> { + // LIMIT 0 obtains column metadata without materializing any row values, + // including prompt or content fields that may be present in future schemas. + let query = format!("SELECT * FROM \"{table}\" LIMIT 0"); + let statement = connection.prepare(query)?; + Ok(statement.column_names().iter().cloned().collect()) +} + +fn missing_columns(columns: &HashSet, required: &[&str]) -> Vec { + required + .iter() + .filter(|column| !columns.contains(**column)) + .map(|column| (*column).to_string()) + .collect() +} + +fn read_usage_row(statement: &sqlite::Statement<'_>) -> Option { + Some(ZcodeUsageRow { + id: statement.read::(0).ok()?, + session_id: statement.read::(1).ok()?, + started_at: read_timestamp_ms(statement, 2)?, + model_id: statement.read::(3).ok()?, + input_tokens: read_token_column(statement, 4), + output_tokens: read_token_column(statement, 5), + cache_creation_input_tokens: read_token_column(statement, 6), + cache_read_input_tokens: read_token_column(statement, 7), + computed_total_tokens: read_token_column(statement, 8), + provider_id: statement.read::, _>(9).ok().flatten(), + directory: statement.read::, _>(10).ok().flatten(), + version: statement.read::, _>(11).ok().flatten(), + }) +} + +fn read_token_column(statement: &sqlite::Statement<'_>, index: usize) -> u64 { + statement + .read::(index) + .map(|value| value.max(0) as u64) + .or_else(|_| { + statement.read::(index).map(|value| { + if value.is_finite() && value > 0.0 { + value.min(u64::MAX as f64).round() as u64 + } else { + 0 + } + }) + }) + .unwrap_or(0) +} + +fn read_timestamp_ms(statement: &sqlite::Statement<'_>, index: usize) -> Option { + if let Ok(value) = statement.read::(index) { + return Some(value); + } + let value = statement.read::(index).ok()?; + (value.is_finite() && value >= i64::MIN as f64 && value <= i64::MAX as f64) + .then_some(value.round() as i64) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use ccusage_test_support::{EnvVarGuard, fs_fixture}; + + use super::*; + use crate::{PricingMap, cli::CostMode}; + + fn create_db(path: &Path, legacy: bool) { + let db = sqlite::open(path).unwrap(); + if legacy { + db.execute( + "CREATE TABLE model_usage ( + id TEXT PRIMARY KEY, session_id TEXT, started_at INTEGER, model_id TEXT, + status TEXT, input_tokens INTEGER, output_tokens INTEGER, + cache_read_input_tokens INTEGER, computed_total_tokens INTEGER + )", + ) + .unwrap(); + db.execute("CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT)") + .unwrap(); + } else { + db.execute( + "CREATE TABLE model_usage ( + id TEXT PRIMARY KEY, session_id TEXT, started_at INTEGER, model_id TEXT, + provider_id TEXT, status TEXT, input_tokens INTEGER, output_tokens INTEGER, + cache_creation_input_tokens INTEGER, cache_read_input_tokens INTEGER, + computed_total_tokens INTEGER + )", + ) + .unwrap(); + db.execute("CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, version TEXT)") + .unwrap(); + } + } + + fn insert_current_usage(path: &Path, id: &str, status: &str) { + let db = sqlite::open(path).unwrap(); + let mut statement = db + .prepare( + "INSERT INTO model_usage + (id, session_id, started_at, model_id, provider_id, status, input_tokens, + output_tokens, cache_creation_input_tokens, cache_read_input_tokens, + computed_total_tokens) + VALUES (?1, 'session-1', 1786909042666, 'GLM-5.3', + 'builtin:zai-coding-plan', ?2, 100, 10, 15, 25, 110)", + ) + .unwrap(); + statement.bind((1, id)).unwrap(); + statement.bind((2, status)).unwrap(); + statement.next().unwrap(); + } + + fn load(root: &Path) -> Vec { + let _guard = EnvVarGuard::set(super::super::paths::ZCODE_HOME_ENV, root); + let shared = SharedArgs { + mode: CostMode::Display, + timezone: Some("UTC".to_string()), + ..SharedArgs::default() + }; + load_entries(&shared, &PricingMap::load_embedded()).unwrap() + } + + #[test] + fn loads_completed_rows_with_millisecond_precision() { + let fixture = fs_fixture!({}); + let _ = fixture.create_dir_all("cli/db"); + let db_path = fixture.path(super::super::paths::ZCODE_DB_RELATIVE_PATH); + create_db(&db_path, false); + let db = sqlite::open(&db_path).unwrap(); + db.execute("INSERT INTO session VALUES ('session-1', '/project', '0.16.3')") + .unwrap(); + insert_current_usage(&db_path, "usage-1", "completed"); + insert_current_usage(&db_path, "usage-2", "running"); + drop(db); + let entries = load(fixture.root()); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].data.message.id.as_deref(), Some("usage-1")); + assert_eq!(entries[0].data.timestamp, "2026-08-16T19:37:22.666Z"); + assert_eq!(entries[0].project_path.as_ref(), "/project"); + } + + #[test] + fn accepts_legacy_schema_without_optional_columns() { + let fixture = fs_fixture!({}); + let _ = fixture.create_dir_all("cli/db"); + let db_path = fixture.path(super::super::paths::ZCODE_DB_RELATIVE_PATH); + create_db(&db_path, true); + let db = sqlite::open(&db_path).unwrap(); + db.execute( + "INSERT INTO model_usage + VALUES ('usage-legacy', 'session-legacy', 1786909042666, 'custom-model', + 'completed', 100, 10, 25, 110)", + ) + .unwrap(); + db.execute("INSERT INTO session VALUES ('session-legacy', '')") + .unwrap(); + drop(db); + + let entries = load(fixture.root()); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].data.version, None); + assert_eq!(entries[0].project_path.as_ref(), "ZCode"); + assert_eq!(entries[0].data.message.usage.input_tokens, 75); + assert_eq!(entries[0].data.message.usage.cache_read_input_tokens, 25); + } + + #[test] + fn skips_unrelated_schema_and_deduplicates_multiple_homes() { + let first = fs_fixture!({}); + let _ = first.create_dir_all("cli/db"); + let first_db = first.path(super::super::paths::ZCODE_DB_RELATIVE_PATH); + create_db(&first_db, false); + let db = sqlite::open(&first_db).unwrap(); + db.execute("INSERT INTO session VALUES ('session-1', '/project', '0.16.3')") + .unwrap(); + insert_current_usage(&first_db, "usage-1", "completed"); + drop(db); + + let second = fs_fixture!({}); + let _ = second.create_dir_all("cli/db"); + let second_db = second.path(super::super::paths::ZCODE_DB_RELATIVE_PATH); + std::fs::copy(&first_db, &second_db).unwrap(); + let db = sqlite::open(&second_db).unwrap(); + let mut statement = db + .prepare( + "INSERT INTO model_usage + (id, session_id, started_at, model_id, provider_id, status, input_tokens, + output_tokens, cache_creation_input_tokens, cache_read_input_tokens, + computed_total_tokens) + VALUES ('usage-2', 'session-1', 1786909043666, 'GLM-5.3', + 'builtin:zai-coding-plan', 'completed', 50, 5, 0, 0, 55)", + ) + .unwrap(); + statement.next().unwrap(); + drop(statement); + drop(db); + + let _guard = EnvVarGuard::set( + super::super::paths::ZCODE_HOME_ENV, + format!("{},{}", first.root().display(), second.root().display()), + ); + let shared = SharedArgs { + mode: CostMode::Display, + timezone: Some("UTC".to_string()), + ..SharedArgs::default() + }; + let entries = load_entries(&shared, &PricingMap::load_embedded()).unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].data.message.id.as_deref(), Some("usage-1")); + assert_eq!(entries[1].data.message.id.as_deref(), Some("usage-2")); + drop(_guard); + + let invalid = fs_fixture!({}); + let _ = invalid.create_dir_all("cli/db"); + let invalid_db = invalid.path(super::super::paths::ZCODE_DB_RELATIVE_PATH); + let db = sqlite::open(&invalid_db).unwrap(); + db.execute("CREATE TABLE unrelated (id TEXT)").unwrap(); + drop(db); + assert!(load(invalid.root()).is_empty()); + } +} diff --git a/rust/adapters/zcode/src/parser.rs b/rust/adapters/zcode/src/parser.rs new file mode 100644 index 0000000000..404d3c4081 --- /dev/null +++ b/rust/adapters/zcode/src/parser.rs @@ -0,0 +1,334 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use jiff::tz::TimeZone as JiffTimeZone; + +use crate::{ + LoadedEntry, PricingMap, TimestampMs, TokenUsageRaw, UsageEntry, UsageMessage, + apply_total_token_fallback, calculate_cost_for_usage, + cli::{CostMode, PricingOverride}, + format_date_tz, format_rfc3339_millis, total_usage_tokens, +}; + +pub(super) struct ZcodeUsageRow { + pub(super) id: String, + pub(super) session_id: String, + pub(super) started_at: i64, + pub(super) model_id: String, + pub(super) provider_id: Option, + pub(super) input_tokens: u64, + pub(super) output_tokens: u64, + pub(super) cache_creation_input_tokens: u64, + pub(super) cache_read_input_tokens: u64, + pub(super) computed_total_tokens: u64, + pub(super) directory: Option, + pub(super) version: Option, +} + +pub(super) fn row_to_entry( + row: ZcodeUsageRow, + tz: Option<&JiffTimeZone>, + mode: CostMode, + pricing: &PricingMap, + pricing_overrides: &BTreeMap, +) -> Option { + if row.id.trim().is_empty() + || row.session_id.trim().is_empty() + || row.model_id.trim().is_empty() + || row.started_at <= 0 + { + return None; + } + + let cache_read = row.cache_read_input_tokens.min(row.input_tokens); + let cache_creation = row + .cache_creation_input_tokens + .min(row.input_tokens.saturating_sub(cache_read)); + let usage = TokenUsageRaw { + input_tokens: row + .input_tokens + .saturating_sub(cache_read) + .saturating_sub(cache_creation), + output_tokens: row.output_tokens, + cache_creation_input_tokens: cache_creation, + cache_read_input_tokens: cache_read, + speed: None, + cache_creation: None, + }; + let (usage, extra_total_tokens) = + apply_total_token_fallback(usage, 0, row.computed_total_tokens); + if total_usage_tokens(usage) == 0 && extra_total_tokens == 0 { + return None; + } + + let raw_model = row.model_id.trim().to_string(); + let cost_model = pricing_model( + &raw_model, + row.provider_id.as_deref(), + pricing, + pricing_overrides, + ); + let timestamp = TimestampMs::from_millis(row.started_at); + let project_path = row + .directory + .filter(|directory| !directory.trim().is_empty()) + .unwrap_or_else(|| "ZCode".to_string()); + let cost_usage = TokenUsageRaw { + output_tokens: usage.output_tokens.saturating_add(extra_total_tokens), + ..usage + }; + let cost = if mode == CostMode::Display { + 0.0 + } else { + calculate_cost_for_usage( + cost_model.as_deref(), + cost_usage, + None, + CostMode::Calculate, + Some(pricing), + ) + }; + let missing_pricing_model = if mode == CostMode::Display || cost_model.is_some() { + None + } else { + (total_usage_tokens(usage).saturating_add(extra_total_tokens) > 0) + .then(|| ccusage_core::model_aliases::resolve_model_name(&raw_model).into_owned()) + }; + + Some(LoadedEntry { + data: UsageEntry { + session_id: Some(row.session_id.clone()), + timestamp: format_rfc3339_millis(timestamp), + version: row.version, + message: UsageMessage { + usage, + model: Some(raw_model.clone()), + id: Some(row.id), + }, + cost_usd: None, + request_id: None, + is_api_error_message: None, + is_sidechain: None, + }, + date: format_date_tz(timestamp, tz), + timestamp, + project: Arc::from("zcode"), + session_id: Arc::from(row.session_id), + project_path: Arc::from(project_path), + cost, + extra_total_tokens, + credits: None, + message_count: None, + model: Some(raw_model), + usage_limit_reset_time: None, + missing_pricing_model, + }) +} + +fn pricing_candidates(raw_model: &str, provider_id: Option<&str>) -> Vec { + let raw_model = raw_model.trim(); + let lower_model = raw_model.to_ascii_lowercase(); + let mut candidates = vec![raw_model.to_string(), lower_model.clone()]; + let is_implicit_zai = provider_id.is_none() && is_zai_model(&lower_model); + if is_zai_provider(provider_id) || is_implicit_zai { + candidates.push(format!("zai/{raw_model}")); + candidates.push(format!("zai/{lower_model}")); + } + candidates.dedup(); + candidates +} + +fn pricing_model( + raw_model: &str, + provider_id: Option<&str>, + pricing: &PricingMap, + pricing_overrides: &BTreeMap, +) -> Option { + let candidates = pricing_candidates(raw_model, provider_id); + if let Some(candidate) = candidates + .iter() + .find(|candidate| pricing_overrides.contains_key(*candidate)) + { + return Some(candidate.clone()); + } + if provider_id.is_some_and(|provider| !is_zai_provider(Some(provider))) { + return None; + } + + if let Some(candidate) = candidates + .iter() + .filter(|candidate| candidate.starts_with("zai/")) + .find(|candidate| pricing.find(candidate).is_some()) + { + return Some(candidate.clone()); + } + candidates + .iter() + .filter(|candidate| !candidate.starts_with("zai/")) + .find(|candidate| pricing.find(candidate).is_some()) + .cloned() +} + +fn is_zai_provider(provider_id: Option<&str>) -> bool { + provider_id.is_some_and(|provider| { + matches!( + provider.trim().to_ascii_lowercase().as_str(), + "zai" | "z.ai" | "zai-coding-plan" | "builtin:zai-coding-plan" + ) + }) +} + +fn is_zai_model(model: &str) -> bool { + model.starts_with("glm-") || model.starts_with("glm/") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::CostMode; + + fn row() -> ZcodeUsageRow { + ZcodeUsageRow { + id: "usage-1".to_string(), + session_id: "session-1".to_string(), + started_at: 1_786_909_042_666, + model_id: "GLM-5.3".to_string(), + provider_id: Some("builtin:zai-coding-plan".to_string()), + input_tokens: 100, + output_tokens: 10, + cache_creation_input_tokens: 15, + cache_read_input_tokens: 25, + computed_total_tokens: 110, + directory: Some("/workspace/project".to_string()), + version: Some("0.16.3".to_string()), + } + } + + #[test] + fn normalizes_both_inclusive_cache_buckets() { + let entry = row_to_entry( + row(), + Some(&JiffTimeZone::UTC), + CostMode::Display, + &PricingMap::load_embedded(), + &BTreeMap::new(), + ) + .unwrap(); + let usage = entry.data.message.usage; + + assert_eq!(usage.input_tokens, 60); + assert_eq!(usage.cache_creation_input_tokens, 15); + assert_eq!(usage.cache_read_input_tokens, 25); + assert_eq!(usage.output_tokens, 10); + assert_eq!(total_usage_tokens(usage), 110); + assert_eq!(entry.data.message.model.as_deref(), Some("GLM-5.3")); + assert_eq!(entry.data.version.as_deref(), Some("0.16.3")); + assert_eq!(entry.data.timestamp, "2026-08-16T19:37:22.666Z"); + } + + #[test] + fn prices_zai_models_but_not_unknown_custom_providers() { + let pricing = PricingMap::load_embedded(); + let priced = row_to_entry( + row(), + Some(&JiffTimeZone::UTC), + CostMode::Calculate, + &pricing, + &BTreeMap::new(), + ) + .unwrap(); + assert!(priced.cost > 0.0); + assert!(priced.missing_pricing_model.is_none()); + + let mut custom = row(); + custom.provider_id = Some("847d13c9-0568-4f2f-818e-8bd498e5d920".to_string()); + custom.model_id = "deepseek-v4-flash".to_string(); + let custom = row_to_entry( + custom, + Some(&JiffTimeZone::UTC), + CostMode::Calculate, + &pricing, + &BTreeMap::new(), + ) + .unwrap(); + assert_eq!(custom.cost, 0.0); + assert_eq!( + custom.missing_pricing_model.as_deref(), + Some("deepseek-v4-flash") + ); + } + + #[test] + fn honors_raw_model_overrides_for_custom_providers() { + let mut row = row(); + row.provider_id = Some("847d13c9-0568-4f2f-818e-8bd498e5d920".to_string()); + row.model_id = "deepseek-v4-flash".to_string(); + let mut overrides = BTreeMap::new(); + overrides.insert( + "deepseek-v4-flash".to_string(), + PricingOverride { + input_cost_per_token: Some(1e-6), + output_cost_per_token: Some(2e-6), + cache_read_input_token_cost: Some(3e-6), + ..Default::default() + }, + ); + let pricing = PricingMap::load_with_overrides(true, false, overrides.iter()); + + let entry = row_to_entry( + row, + Some(&JiffTimeZone::UTC), + CostMode::Calculate, + &pricing, + &overrides, + ) + .unwrap(); + + assert!(entry.cost > 0.0); + assert!(entry.missing_pricing_model.is_none()); + } + + #[test] + fn routes_unaccounted_total_tokens_to_extra_tokens() { + let mut row = row(); + row.computed_total_tokens = 123; + let entry = row_to_entry( + row, + Some(&JiffTimeZone::UTC), + CostMode::Display, + &PricingMap::load_embedded(), + &BTreeMap::new(), + ) + .unwrap(); + assert_eq!(entry.extra_total_tokens, 13); + assert_eq!(entry.data.message.usage.output_tokens, 10); + } + + #[test] + fn returns_no_entry_for_invalid_identity_or_timestamp() { + let mut invalid_row = row(); + invalid_row.id.clear(); + assert!( + row_to_entry( + invalid_row, + Some(&JiffTimeZone::UTC), + CostMode::Display, + &PricingMap::default(), + &BTreeMap::new(), + ) + .is_none() + ); + + let mut invalid_row = row(); + invalid_row.started_at = 0; + assert!( + row_to_entry( + invalid_row, + Some(&JiffTimeZone::UTC), + CostMode::Display, + &PricingMap::default(), + &BTreeMap::new(), + ) + .is_none() + ); + } +} diff --git a/rust/adapters/zcode/src/paths.rs b/rust/adapters/zcode/src/paths.rs new file mode 100644 index 0000000000..fdc0b7344e --- /dev/null +++ b/rust/adapters/zcode/src/paths.rs @@ -0,0 +1,214 @@ +use std::{ + collections::HashSet, + env, fs, + path::{Path, PathBuf}, +}; + +use crate::{Result, cli::SharedArgs, debug_log}; + +pub(crate) const ZCODE_HOME_ENV: &str = "ZCODE_HOME"; +pub(crate) const ZCODE_DB_RELATIVE_PATH: &str = "cli/db/db.sqlite"; + +fn paths(shared: &SharedArgs) -> Result> { + if let Some(raw) = env::var_os(ZCODE_HOME_ENV) { + let configured = configured_roots(raw); + if !configured.is_empty() { + return Ok(unique_roots(configured, shared, true)); + } + } + + let Some(home) = crate::home::home_dir() else { + debug_log(shared, "Unable to resolve the default ZCode home"); + return Ok(Vec::new()); + }; + Ok(unique_roots([home.join(".zcode")], shared, false)) +} + +pub(super) fn db_paths(shared: &SharedArgs) -> Result> { + let mut db_paths = Vec::new(); + let mut seen = HashSet::new(); + for root in paths(shared)? { + let db_path = root.join(ZCODE_DB_RELATIVE_PATH); + let canonical = match fs::canonicalize(&db_path) { + Ok(path) if path.is_file() => path, + Ok(path) => { + debug_log( + shared, + format!("ZCode database is not a file: {}", path.display()), + ); + continue; + } + Err(error) => { + debug_log( + shared, + format!( + "Unable to access ZCode database {}: {error}", + db_path.display() + ), + ); + continue; + } + }; + if seen.insert(canonical.clone()) { + db_paths.push(canonical); + } + } + Ok(db_paths) +} + +fn configured_roots(raw: std::ffi::OsString) -> Vec { + match raw.into_string() { + Ok(value) => value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .collect(), + Err(raw) => vec![PathBuf::from(raw)], + } +} + +fn unique_roots( + roots: impl IntoIterator, + shared: &SharedArgs, + configured: bool, +) -> Vec { + let mut resolved = Vec::new(); + let mut seen = HashSet::new(); + for root in roots { + let Some(root) = canonical_directory(&root, shared, configured) else { + continue; + }; + if seen.insert(root.clone()) { + resolved.push(root); + } + } + resolved +} + +fn canonical_directory(path: &Path, shared: &SharedArgs, configured: bool) -> Option { + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) => { + if configured { + debug_log( + shared, + format!( + "Ignoring configured ZCODE_HOME root {}: {error}", + path.display() + ), + ); + } + return None; + } + }; + if !metadata.is_dir() { + if configured { + debug_log( + shared, + format!( + "Ignoring configured ZCODE_HOME root {}: not a directory", + path.display() + ), + ); + } + return None; + } + match fs::canonicalize(path) { + Ok(path) => Some(path), + Err(error) => { + if configured { + debug_log( + shared, + format!( + "Ignoring configured ZCODE_HOME root {}: {error}", + path.display() + ), + ); + } + None + } + } +} + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + + use ccusage_test_support::{EnvVarsGuard, fs_fixture}; + + use super::*; + + fn shared() -> SharedArgs { + SharedArgs { + debug: true, + ..SharedArgs::default() + } + } + + #[test] + fn discovers_and_deduplicates_configured_roots() { + let fixture = fs_fixture!({}); + let first = fixture.create_dir_all("first"); + let second = fixture.create_dir_all("second"); + let raw = format!( + " {}, {}, {} ", + first.display(), + second.display(), + first.display() + ); + let _guard = EnvVarsGuard::set_many([ + (ZCODE_HOME_ENV, Some(OsString::from(raw))), + ("HOME", None), + ("USERPROFILE", None), + ("HOMEDRIVE", None), + ("HOMEPATH", None), + ]); + + assert_eq!( + paths(&shared()).unwrap(), + vec![ + first.canonicalize().unwrap(), + second.canonicalize().unwrap() + ] + ); + } + + #[test] + fn invalid_configured_roots_do_not_fall_back_to_default() { + let fixture = fs_fixture!({ + ".zcode/cli/db/db.sqlite": "not a database", + }); + let _guard = EnvVarsGuard::set_many([ + ( + ZCODE_HOME_ENV, + Some(fixture.path("missing").into_os_string()), + ), + ("HOME", Some(fixture.root().as_os_str().to_os_string())), + ("USERPROFILE", None), + ("HOMEDRIVE", None), + ("HOMEPATH", None), + ]); + + assert!(paths(&shared()).unwrap().is_empty()); + } + + #[test] + fn empty_configured_value_uses_default_home() { + let fixture = fs_fixture!({ + ".zcode/cli/db/db.sqlite": "not a database", + }); + let _guard = EnvVarsGuard::set_many([ + (ZCODE_HOME_ENV, Some(OsString::new())), + ("HOME", Some(fixture.root().as_os_str().to_os_string())), + ("USERPROFILE", None), + ("HOMEDRIVE", None), + ("HOMEPATH", None), + ]); + + assert_eq!( + paths(&shared()).unwrap(), + vec![fixture.path(".zcode").canonicalize().unwrap()] + ); + } +} diff --git a/rust/adapters/zcode/src/report.rs b/rust/adapters/zcode/src/report.rs new file mode 100644 index 0000000000..3a4ec0a92c --- /dev/null +++ b/rust/adapters/zcode/src/report.rs @@ -0,0 +1,130 @@ +use std::collections::BTreeMap; + +use serde_json::{Value, json}; + +use crate::{ + BucketKind, LoadedEntry, Result, SessionAccumulator, + cli::{AgentReportKind, WeekDay}, + summarize_by_key, summarize_summaries_by_bucket, totals_json, +}; + +pub(crate) fn report_from_rows(rows: &[crate::UsageSummary], kind: AgentReportKind) -> Value { + let rows_json = rows + .iter() + .map(|row| ccusage_core::agent_summary_json(row, kind, kind == AgentReportKind::Session)) + .collect::>(); + json!({ + rows_key(kind): rows_json, + "totals": totals_json(rows), + }) +} + +pub fn summarize_entries( + entries: &[LoadedEntry], + kind: AgentReportKind, +) -> Result> { + match kind { + AgentReportKind::Daily => summarize_by_key( + entries, + |entry| entry.date.clone(), + |date| (date.to_string(), None), + ), + AgentReportKind::Monthly => { + let daily = summarize_entries(entries, AgentReportKind::Daily)?; + Ok(summarize_summaries_by_bucket( + &daily, + BucketKind::Monthly, + WeekDay::Sunday, + )) + } + AgentReportKind::Session => { + let mut groups = BTreeMap::::new(); + for entry in entries { + groups + .entry(entry.session_id.to_string()) + .or_default() + .add_entry(entry); + } + groups + .into_values() + .map(SessionAccumulator::into_summary) + .collect() + } + AgentReportKind::Weekly => { + let daily = summarize_entries(entries, AgentReportKind::Daily)?; + Ok(summarize_summaries_by_bucket( + &daily, + BucketKind::Weekly, + WeekDay::Sunday, + )) + } + } +} + +fn rows_key(kind: AgentReportKind) -> &'static str { + match kind { + AgentReportKind::Daily => "daily", + AgentReportKind::Weekly => "weekly", + AgentReportKind::Monthly => "monthly", + AgentReportKind::Session => "sessions", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{TimestampMs, TokenUsageRaw, UsageEntry, UsageMessage}; + use std::sync::Arc; + + fn entry(session_id: &str, date: &str, millis: i64) -> LoadedEntry { + let timestamp = TimestampMs::from_millis(millis); + LoadedEntry { + data: UsageEntry { + session_id: Some(session_id.to_string()), + timestamp: format!("{date}T12:00:00.000Z"), + version: Some("0.16.3".to_string()), + message: UsageMessage { + usage: TokenUsageRaw { + input_tokens: 60, + output_tokens: 10, + cache_creation_input_tokens: 15, + cache_read_input_tokens: 25, + speed: None, + cache_creation: None, + }, + model: Some("GLM-5.3".to_string()), + id: Some(format!("usage-{millis}")), + }, + cost_usd: None, + request_id: None, + is_api_error_message: None, + is_sidechain: None, + }, + timestamp, + date: date.to_string(), + project: Arc::from("zcode"), + session_id: Arc::from(session_id), + project_path: Arc::from("/project"), + cost: 0.01, + extra_total_tokens: 0, + credits: None, + message_count: None, + model: Some("GLM-5.3".to_string()), + usage_limit_reset_time: None, + missing_pricing_model: None, + } + } + + #[test] + fn reports_daily_and_session_totals() { + let entries = [entry("session-1", "2026-08-16", 1_786_909_042_666)]; + let daily = summarize_entries(&entries, AgentReportKind::Daily).unwrap(); + let report = report_from_rows(&daily, AgentReportKind::Daily); + assert_eq!(report["daily"][0]["totalTokens"], 110); + assert_eq!(report["totals"]["totalTokens"], 110); + + let sessions = summarize_entries(&entries, AgentReportKind::Session).unwrap(); + assert_eq!(sessions[0].session_id.as_deref(), Some("session-1")); + assert_eq!(sessions[0].project_path.as_deref(), Some("/project")); + } +} diff --git a/rust/crates/ccusage-adapter-all/Cargo.toml b/rust/crates/ccusage-adapter-all/Cargo.toml index 88aae66ff3..9e7a424b11 100644 --- a/rust/crates/ccusage-adapter-all/Cargo.toml +++ b/rust/crates/ccusage-adapter-all/Cargo.toml @@ -22,6 +22,7 @@ ccusage-adapter-openclaw.workspace = true ccusage-adapter-opencode.workspace = true ccusage-adapter-pi.workspace = true ccusage-adapter-qwen.workspace = true +ccusage-adapter-zcode.workspace = true ccusage-cli.workspace = true ccusage-core.workspace = true serde.workspace = true diff --git a/rust/crates/ccusage-adapter-all/README.md b/rust/crates/ccusage-adapter-all/README.md index 9c4b713b1e..08ccde62d5 100644 --- a/rust/crates/ccusage-adapter-all/README.md +++ b/rust/crates/ccusage-adapter-all/README.md @@ -10,7 +10,7 @@ into one table or JSON document. - `report.rs` โ€” the unified row and total shapes. - `types.rs` โ€” the accumulators the merge needs. -This is the only crate that depends on all 16 adapters, which keeps the adapters +This is the only crate that depends on all 17 adapters, which keeps the adapters themselves independent of each other. ## Public surface @@ -36,6 +36,7 @@ themselves independent of each other. - `ccusage-adapter-opencode` - `ccusage-adapter-pi` - `ccusage-adapter-qwen` +- `ccusage-adapter-zcode` - `ccusage-cli` - `ccusage-core` - `serde` diff --git a/rust/crates/ccusage-adapter-all/src/lib.rs b/rust/crates/ccusage-adapter-all/src/lib.rs index efb1dc5507..ec3b9221aa 100644 --- a/rust/crates/ccusage-adapter-all/src/lib.rs +++ b/rust/crates/ccusage-adapter-all/src/lib.rs @@ -25,6 +25,7 @@ mod adapter { pub use ccusage_adapter_opencode as opencode; pub use ccusage_adapter_pi as pi; pub use ccusage_adapter_qwen as qwen; + pub use ccusage_adapter_zcode as zcode; } use crate::{ diff --git a/rust/crates/ccusage-adapter-all/src/loader.rs b/rust/crates/ccusage-adapter-all/src/loader.rs index 7327767f4b..e79140c319 100644 --- a/rust/crates/ccusage-adapter-all/src/loader.rs +++ b/rust/crates/ccusage-adapter-all/src/loader.rs @@ -13,7 +13,7 @@ use crate::{ SessionAccumulator, UsageSummary, adapter::{ amp, claude, codebuff, codex, copilot, droid, gemini, goose, grok, hermes, kilo, kimi, - openclaw, opencode, pi, qwen, + openclaw, opencode, pi, qwen, zcode, }, cli::{AgentReportKind, CodexSpeed, NamedPiStore, SharedArgs, WeekDay}, filter_loaded_entries_by_date, json_float, @@ -326,6 +326,21 @@ fn load_base_rows( Ok(rows) }), }, + AgentLoadSpec { + index: 16, + agent: BUILT_IN_AGENT_NAMES[16], + progress_agent: crate::progress::UsageLoadAgent("ZCode"), + load: Box::new(|| { + load_priced_summary_agent_rows( + "zcode", + load_kind, + &loader_shared, + pricing, + zcode::load_entries, + zcode::summarize_entries, + ) + }), + }, ]; let named_pi_stores = resolve_named_pi_store_paths(&shared.pi_stores)?; for store in named_pi_stores { diff --git a/rust/crates/ccusage-adapter-all/src/report.rs b/rust/crates/ccusage-adapter-all/src/report.rs index fb39255d85..b42d7395cb 100644 --- a/rust/crates/ccusage-adapter-all/src/report.rs +++ b/rust/crates/ccusage-adapter-all/src/report.rs @@ -856,6 +856,7 @@ fn agent_label(agent: &str) -> &str { "kimi" => "Kimi", "qwen" => "Qwen", "grok" => "Grok", + "zcode" => "ZCode", _ => agent, } } diff --git a/rust/crates/ccusage-adapter-all/src/tests.rs b/rust/crates/ccusage-adapter-all/src/tests.rs index 2016f91681..43971ebfb5 100644 --- a/rust/crates/ccusage-adapter-all/src/tests.rs +++ b/rust/crates/ccusage-adapter-all/src/tests.rs @@ -730,6 +730,7 @@ fn isolated_agent_env( "KIMI_DATA_DIR", "QWEN_DATA_DIR", "GROK_HOME", + "ZCODE_HOME", ] .into_iter() .map(|key| (key, None::)) diff --git a/rust/crates/ccusage-cli-parser/src/cli-commands.json b/rust/crates/ccusage-cli-parser/src/cli-commands.json index 0387a25017..29ee014d47 100644 --- a/rust/crates/ccusage-cli-parser/src/cli-commands.json +++ b/rust/crates/ccusage-cli-parser/src/cli-commands.json @@ -104,6 +104,10 @@ { "name": "grok", "description": "Show Grok Build CLI usage commands" + }, + { + "name": "zcode", + "description": "Show ZCode usage commands" } ] }, @@ -775,6 +779,43 @@ "description": "Show Grok Build CLI usage grouped by session", "usage": "ccusage grok session ", "options": "agent_options" + }, + { + "path": ["zcode"], + "description": "Usage reports for ZCode.", + "usage": "ccusage zcode ", + "commands": [ + { + "name": "daily", + "description": "Show ZCode usage grouped by date" + }, + { + "name": "monthly", + "description": "Show ZCode usage grouped by month" + }, + { + "name": "session", + "description": "Show ZCode usage grouped by session" + } + ] + }, + { + "path": ["zcode", "daily"], + "description": "Show ZCode usage grouped by date", + "usage": "ccusage zcode daily ", + "options": "agent_period_options" + }, + { + "path": ["zcode", "monthly"], + "description": "Show ZCode usage grouped by month", + "usage": "ccusage zcode monthly ", + "options": "agent_period_options" + }, + { + "path": ["zcode", "session"], + "description": "Show ZCode usage grouped by session", + "usage": "ccusage zcode session ", + "options": "agent_options" } ] } diff --git a/rust/crates/ccusage-cli-parser/src/parser.rs b/rust/crates/ccusage-cli-parser/src/parser.rs index c793882e32..a3eaaea538 100644 --- a/rust/crates/ccusage-cli-parser/src/parser.rs +++ b/rust/crates/ccusage-cli-parser/src/parser.rs @@ -342,6 +342,13 @@ fn parse_command( STANDARD_AGENT_REPORTS, Command::Grok, ), + "zcode" => parse_basic_agent_command( + parser, + shared, + "zcode", + STANDARD_AGENT_REPORTS, + Command::ZCode, + ), _ => Err(format!("Unknown command '{command}'")), } } @@ -797,6 +804,7 @@ fn is_command(arg: &str) -> bool { | "kimi" | "qwen" | "grok" + | "zcode" ) } @@ -957,6 +965,7 @@ fn is_agent_command(command: &str) -> bool { | "qwen" | "openclaw" | "grok" + | "zcode" ) } @@ -969,7 +978,7 @@ fn agent_report_supported(agent: &str, report: &str) -> bool { "codex" => matches!(report, "daily" | "monthly" | "session"), "opencode" => matches!(report, "daily" | "weekly" | "monthly" | "session"), "amp" | "droid" | "codebuff" | "hermes" | "pi" | "goose" | "kilo" | "copilot" - | "gemini" | "kimi" | "qwen" | "openclaw" | "grok" => { + | "gemini" | "kimi" | "qwen" | "openclaw" | "grok" | "zcode" => { matches!(report, "daily" | "monthly" | "session") } _ => false, @@ -994,6 +1003,7 @@ fn agent_display_name(agent: &str) -> &'static str { "qwen" => "Qwen", "openclaw" => "OpenClaw", "grok" => "Grok", + "zcode" => "ZCode", _ => unreachable!("agent is prevalidated"), } } @@ -1074,7 +1084,8 @@ fn last_option_error(command: Option<&Command>, root_shared: &SharedArgs) -> Opt | Command::Kimi(args) | Command::Qwen(args) | Command::OpenClaw(args) - | Command::Grok(args), + | Command::Grok(args) + | Command::ZCode(args), ) => (&args.shared, args.kind != AgentReportKind::Session), }; shared.last?; diff --git a/rust/crates/ccusage-cli-parser/src/snapshots/ccusage_cli_parser__tests__root_help.snap b/rust/crates/ccusage-cli-parser/src/snapshots/ccusage_cli_parser__tests__root_help.snap index b39502449f..5d4afdfca1 100644 --- a/rust/crates/ccusage-cli-parser/src/snapshots/ccusage_cli_parser__tests__root_help.snap +++ b/rust/crates/ccusage-cli-parser/src/snapshots/ccusage_cli_parser__tests__root_help.snap @@ -29,6 +29,7 @@ COMMANDS: qwen Show Qwen usage commands openclaw Show OpenClaw usage commands grok Show Grok Build CLI usage commands + zcode Show ZCode usage commands For more info, run any command with the `--help` flag: ccusage daily --help @@ -53,6 +54,7 @@ For more info, run any command with the `--help` flag: ccusage qwen --help ccusage openclaw --help ccusage grok --help + ccusage zcode --help OPTIONS: -j, --json Output in JSON format (default: false) diff --git a/rust/crates/ccusage-cli-parser/src/snapshots/ccusage_cli_parser__tests__snapshots_representative_cli_parse_shapes.snap b/rust/crates/ccusage-cli-parser/src/snapshots/ccusage_cli_parser__tests__snapshots_representative_cli_parse_shapes.snap index 223959f19c..46d7c7164c 100644 --- a/rust/crates/ccusage-cli-parser/src/snapshots/ccusage_cli_parser__tests__snapshots_representative_cli_parse_shapes.snap +++ b/rust/crates/ccusage-cli-parser/src/snapshots/ccusage_cli_parser__tests__snapshots_representative_cli_parse_shapes.snap @@ -436,6 +436,58 @@ expression: cases } } }, + { + "case": "zcode daily", + "cli": { + "command": { + "byAgent": false, + "codexSpeed": "Auto", + "kind": "Daily", + "openClawPath": null, + "piPath": null, + "sections": null, + "shared": { + "breakdown": false, + "color": false, + "compact": false, + "config": null, + "debug": false, + "debugSamples": 5, + "jq": null, + "json": true, + "mode": "Auto", + "noColor": false, + "noOffline": false, + "offline": false, + "order": "Asc", + "since": null, + "singleThread": false, + "timezone": null, + "until": null + }, + "type": "zcode" + }, + "shared": { + "breakdown": false, + "color": false, + "compact": false, + "config": null, + "debug": false, + "debugSamples": 5, + "jq": null, + "json": false, + "mode": "Auto", + "noColor": false, + "noOffline": false, + "offline": false, + "order": "Asc", + "since": null, + "singleThread": false, + "timezone": null, + "until": null + } + } + }, { "case": "blocks active recent", "cli": { diff --git a/rust/crates/ccusage-cli-parser/src/tests.rs b/rust/crates/ccusage-cli-parser/src/tests.rs index a1daa1d78a..068efa145e 100644 --- a/rust/crates/ccusage-cli-parser/src/tests.rs +++ b/rust/crates/ccusage-cli-parser/src/tests.rs @@ -210,6 +210,7 @@ fn command_snapshot(command: Option) -> Value { Some(Command::Qwen(args)) => agent_command_snapshot("qwen", args), Some(Command::OpenClaw(args)) => agent_command_snapshot("openclaw", args), Some(Command::Grok(args)) => agent_command_snapshot("grok", args), + Some(Command::ZCode(args)) => agent_command_snapshot("zcode", args), } } @@ -662,7 +663,7 @@ fn root_help_lists_agent_namespaces_without_nested_commands() { let help = help_text(); let agents = [ "claude", "codex", "opencode", "amp", "droid", "codebuff", "hermes", "pi", "goose", "kilo", - "copilot", "gemini", "kimi", "qwen", "openclaw", "grok", + "copilot", "gemini", "kimi", "qwen", "openclaw", "grok", "zcode", ]; for agent in agents { @@ -879,6 +880,10 @@ fn snapshots_representative_cli_parse_shapes() { "case": "grok daily", "cli": cli_snapshot(parse(&["ccusage", "grok", "daily", "--json"])), }), + json!({ + "case": "zcode daily", + "cli": cli_snapshot(parse(&["ccusage", "zcode", "daily", "--json"])), + }), json!({ "case": "blocks active recent", "cli": cli_snapshot(parse(&[ @@ -1276,6 +1281,16 @@ fn parses_grok_daily_options() { assert!(args.shared.json); } +#[test] +fn parses_zcode_daily_options() { + let cli = parse(&["ccusage", "zcode", "daily", "--json"]); + let Some(Command::ZCode(args)) = cli.command else { + panic!("expected zcode command"); + }; + assert_eq!(args.kind, AgentReportKind::Daily); + assert!(args.shared.json); +} + #[test] fn rejects_grok_path_option() { let error = parse_error(&["ccusage", "grok", "daily", "--grok-path", "/tmp/grok-home"]); diff --git a/rust/crates/ccusage-cli/src/types.rs b/rust/crates/ccusage-cli/src/types.rs index 514b2219a4..925c7df3bb 100644 --- a/rust/crates/ccusage-cli/src/types.rs +++ b/rust/crates/ccusage-cli/src/types.rs @@ -26,6 +26,7 @@ pub enum Command { Qwen(AgentCommandArgs), OpenClaw(AgentCommandArgs), Grok(AgentCommandArgs), + ZCode(AgentCommandArgs), } #[derive(Clone, Debug, Default)] diff --git a/rust/crates/ccusage-config/src/config.rs b/rust/crates/ccusage-config/src/config.rs index 41654b81a4..05a1430a64 100644 --- a/rust/crates/ccusage-config/src/config.rs +++ b/rust/crates/ccusage-config/src/config.rs @@ -1031,6 +1031,27 @@ mod tests { assert!(shared.json); } + #[test] + fn zcode_namespace_keeps_shared_report_options() { + let config = context( + json!({ + "zcode": { + "defaults": { "offline": true }, + "commands": { "session": { "json": true } } + } + }), + "zcode session", + Some("zcode"), + "session", + ); + let mut shared = SharedArgs::default(); + + apply_config_to_shared(&mut shared, &config); + + assert!(shared.offline); + assert!(shared.json); + } + #[test] fn merge_pricing_overrides_field_level_preserves_parent_fields() { use crate::config_schema::ConfigPricingOverride; diff --git a/rust/crates/ccusage-config/src/config_schema.rs b/rust/crates/ccusage-config/src/config_schema.rs index 69a54525aa..913a8e7d9a 100644 --- a/rust/crates/ccusage-config/src/config_schema.rs +++ b/rust/crates/ccusage-config/src/config_schema.rs @@ -52,6 +52,8 @@ pub struct CcusageConfig { pub qwen: Option, /// Grok Build CLI configuration. pub grok: Option, + /// ZCode configuration. + pub zcode: Option, } #[derive(Debug, Default, Deserialize, JsonSchema)] @@ -321,6 +323,21 @@ pub struct GrokCommandsConfig { pub session: Option, } +#[derive(Debug, Default, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ZCodeConfig { + pub defaults: Option, + pub commands: Option, +} + +#[derive(Debug, Default, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ZCodeCommandsConfig { + pub daily: Option, + pub monthly: Option, + pub session: Option, +} + #[derive(Debug, Default, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct SharedOptions { @@ -1104,6 +1121,7 @@ mod tests { &with_keys(&shared, &["openClawPath"]), ); assert_schema_properties(&schema, &["grok", "defaults"], &shared); + assert_schema_properties(&schema, &["zcode", "defaults"], &shared); } #[test] @@ -1160,7 +1178,7 @@ mod tests { &[ "$schema", "amp", "claude", "codebuff", "codex", "commands", "copilot", "defaults", "droid", "gemini", "goose", "grok", "hermes", "kilo", "kimi", "opencode", - "openclaw", "pi", "qwen", + "openclaw", "pi", "qwen", "zcode", ], ); assert!( @@ -1447,6 +1465,7 @@ mod tests { "piDefaults": schema_node(&schema, &["pi", "defaults"]), "openclawDefaults": schema_node(&schema, &["openclaw", "defaults"]), "grokDefaults": schema_node(&schema, &["grok", "defaults"]), + "zcodeDefaults": schema_node(&schema, &["zcode", "defaults"]), })); } diff --git a/rust/crates/ccusage-config/src/snapshots/ccusage_config__config_schema__tests__snapshots_schema_agent_specific_option_edges.snap b/rust/crates/ccusage-config/src/snapshots/ccusage_config__config_schema__tests__snapshots_schema_agent_specific_option_edges.snap index 68a2d888a0..f0585494e4 100644 --- a/rust/crates/ccusage-config/src/snapshots/ccusage_config__config_schema__tests__snapshots_schema_agent_specific_option_edges.snap +++ b/rust/crates/ccusage-config/src/snapshots/ccusage_config__config_schema__tests__snapshots_schema_agent_specific_option_edges.snap @@ -1,6 +1,6 @@ --- source: crates/ccusage-config/src/config_schema.rs -expression: "json!({\n \"rootRef\": schema[\"$ref\"], \"rootProperties\":\n definition_properties(&schema, \"ccusage-config\"),\n \"rootAdditionalProperties\":\n schema[\"definitions\"][\"ccusage-config\"][\"additionalProperties\"],\n \"defaults\": schema_node(&schema, &[\"defaults\"]), \"rootDaily\":\n schema_node(&schema, &[\"commands\", \"daily\"]), \"claudeStatusline\":\n schema_node(&schema, &[\"claude\", \"commands\", \"statusline\"]),\n \"codexDefaults\": schema_node(&schema, &[\"codex\", \"defaults\"]),\n \"opencodeWeekly\":\n schema_node(&schema, &[\"opencode\", \"commands\", \"weekly\"]), \"piDefaults\":\n schema_node(&schema, &[\"pi\", \"defaults\"]), \"openclawDefaults\":\n schema_node(&schema, &[\"openclaw\", \"defaults\"]), \"grokDefaults\":\n schema_node(&schema, &[\"grok\", \"defaults\"]),\n})" +expression: "json!({\n \"rootRef\": schema[\"$ref\"], \"rootProperties\":\n definition_properties(&schema, \"ccusage-config\"),\n \"rootAdditionalProperties\":\n schema[\"definitions\"][\"ccusage-config\"][\"additionalProperties\"],\n \"defaults\": schema_node(&schema, &[\"defaults\"]), \"rootDaily\":\n schema_node(&schema, &[\"commands\", \"daily\"]), \"claudeStatusline\":\n schema_node(&schema, &[\"claude\", \"commands\", \"statusline\"]),\n \"codexDefaults\": schema_node(&schema, &[\"codex\", \"defaults\"]),\n \"opencodeWeekly\":\n schema_node(&schema, &[\"opencode\", \"commands\", \"weekly\"]), \"piDefaults\":\n schema_node(&schema, &[\"pi\", \"defaults\"]), \"openclawDefaults\":\n schema_node(&schema, &[\"openclaw\", \"defaults\"]), \"grokDefaults\":\n schema_node(&schema, &[\"grok\", \"defaults\"]), \"zcodeDefaults\":\n schema_node(&schema, &[\"zcode\", \"defaults\"]),\n})" --- { "claudeStatusline": { @@ -1371,7 +1371,181 @@ expression: "json!({\n \"rootRef\": schema[\"$ref\"], \"rootProperties\":\n "openclaw", "opencode", "pi", - "qwen" + "qwen", + "zcode" ], - "rootRef": "#/definitions/ccusage-config" + "rootRef": "#/definitions/ccusage-config", + "zcodeDefaults": { + "additionalProperties": false, + "properties": { + "all": { + "default": false, + "description": "Accepted for compatibility; all detected supported agents are included by default.", + "markdownDescription": "Accepted for compatibility; all detected supported agents are included by default.", + "type": "boolean" + }, + "breakdown": { + "default": false, + "description": "Show per-model cost breakdown.", + "markdownDescription": "Show per-model cost breakdown.", + "type": "boolean" + }, + "color": { + "default": false, + "description": "Enable colored output.", + "markdownDescription": "Enable colored output.", + "type": "boolean" + }, + "compact": { + "default": false, + "description": "Force compact table layout for narrow terminals.", + "markdownDescription": "Force compact table layout for narrow terminals.", + "type": "boolean" + }, + "debug": { + "default": false, + "description": "Show pricing mismatch information for debugging.", + "markdownDescription": "Show pricing mismatch information for debugging.", + "type": "boolean" + }, + "debugSamples": { + "default": 5, + "description": "Number of sample discrepancies to show in debug output.", + "format": "uint", + "markdownDescription": "Number of sample discrepancies to show in debug output.", + "minimum": 0.0, + "type": "integer" + }, + "jq": { + "description": "jq filter to apply to JSON output.", + "markdownDescription": "jq filter to apply to JSON output.", + "type": "string" + }, + "json": { + "default": false, + "description": "Output in JSON format.", + "markdownDescription": "Output in JSON format.", + "type": "boolean" + }, + "mode": { + "default": "auto", + "description": "Cost calculation mode.", + "enum": [ + "auto", + "calculate", + "display" + ], + "markdownDescription": "Cost calculation mode.", + "type": "string" + }, + "noColor": { + "default": false, + "description": "Disable colored output.", + "markdownDescription": "Disable colored output.", + "type": "boolean" + }, + "noCost": { + "default": false, + "description": "Hide cost information in table and JSON output.", + "markdownDescription": "Hide cost information in table and JSON output.", + "type": "boolean" + }, + "noOffline": { + "default": false, + "description": "Disable cached pricing data where supported.", + "markdownDescription": "Disable cached pricing data where supported.", + "type": "boolean" + }, + "offline": { + "default": false, + "description": "Use cached pricing data where supported.", + "markdownDescription": "Use cached pricing data where supported.", + "type": "boolean" + }, + "order": { + "default": "asc", + "description": "Sort order.", + "enum": [ + "desc", + "asc" + ], + "markdownDescription": "Sort order.", + "type": "string" + }, + "pricingOverrides": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "cacheCreationInputTokenCost": { + "format": "double", + "type": "number" + }, + "cacheCreationInputTokenCostAbove200kTokens": { + "format": "double", + "type": "number" + }, + "cacheReadInputTokenCost": { + "format": "double", + "type": "number" + }, + "cacheReadInputTokenCostAbove200kTokens": { + "format": "double", + "type": "number" + }, + "fastMultiplier": { + "format": "double", + "type": "number" + }, + "inputCostPerToken": { + "format": "double", + "type": "number" + }, + "inputCostPerTokenAbove200kTokens": { + "format": "double", + "type": "number" + }, + "maxInputTokens": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "outputCostPerToken": { + "format": "double", + "type": "number" + }, + "outputCostPerTokenAbove200kTokens": { + "format": "double", + "type": "number" + } + }, + "type": "object" + }, + "description": "Runtime pricing overrides keyed by raw model name.", + "markdownDescription": "Runtime pricing overrides keyed by raw model name.", + "type": "object" + }, + "since": { + "description": "Filter from date (YYYY-MM-DD or YYYYMMDD).", + "markdownDescription": "Filter from date (YYYY-MM-DD or YYYYMMDD).", + "type": "string" + }, + "singleThread": { + "default": false, + "description": "Disable parallel file processing.", + "markdownDescription": "Disable parallel file processing.", + "type": "boolean" + }, + "timezone": { + "description": "Timezone for date grouping (IANA).", + "markdownDescription": "Timezone for date grouping (IANA).", + "type": "string" + }, + "until": { + "description": "Filter until date (inclusive).", + "markdownDescription": "Filter until date (inclusive).", + "type": "string" + } + }, + "type": "object" + } } diff --git a/rust/crates/ccusage-core/src/lib.rs b/rust/crates/ccusage-core/src/lib.rs index 505abaf820..f460c4a14a 100644 --- a/rust/crates/ccusage-core/src/lib.rs +++ b/rust/crates/ccusage-core/src/lib.rs @@ -57,7 +57,7 @@ pub const USAGE_COMPACT_WIDTH_THRESHOLD: usize = 100; pub const BUILT_IN_AGENT_NAMES: &[&str] = &[ "claude", "codex", "opencode", "amp", "droid", "codebuff", "hermes", "pi", "goose", "openclaw", - "kilo", "copilot", "gemini", "kimi", "qwen", "grok", + "kilo", "copilot", "gemini", "kimi", "qwen", "grok", "zcode", ]; pub type Result = std::result::Result; diff --git a/rust/crates/ccusage/Cargo.toml b/rust/crates/ccusage/Cargo.toml index 2cbb836395..7d663fefb8 100644 --- a/rust/crates/ccusage/Cargo.toml +++ b/rust/crates/ccusage/Cargo.toml @@ -33,6 +33,7 @@ ccusage-adapter-openclaw.workspace = true ccusage-adapter-opencode.workspace = true ccusage-adapter-pi.workspace = true ccusage-adapter-qwen.workspace = true +ccusage-adapter-zcode.workspace = true ccusage-cli-parser.workspace = true ccusage-cli.workspace = true ccusage-config.workspace = true diff --git a/rust/crates/ccusage/src/adapter/mod.rs b/rust/crates/ccusage/src/adapter/mod.rs index d69d448546..57cc6c6ccc 100644 --- a/rust/crates/ccusage/src/adapter/mod.rs +++ b/rust/crates/ccusage/src/adapter/mod.rs @@ -15,3 +15,4 @@ pub(crate) use ccusage_adapter_openclaw as openclaw; pub(crate) use ccusage_adapter_opencode as opencode; pub(crate) use ccusage_adapter_pi as pi; pub(crate) use ccusage_adapter_qwen as qwen; +pub(crate) use ccusage_adapter_zcode as zcode; diff --git a/rust/crates/ccusage/src/cli/last_window.rs b/rust/crates/ccusage/src/cli/last_window.rs index 1155ee84c2..cde29e037b 100644 --- a/rust/crates/ccusage/src/cli/last_window.rs +++ b/rust/crates/ccusage/src/cli/last_window.rs @@ -49,7 +49,8 @@ fn window_target(cli: &mut Cli) -> Option<(&mut SharedArgs, PeriodUnit, WeekDay) | Command::Kimi(args) | Command::Qwen(args) | Command::OpenClaw(args) - | Command::Grok(args), + | Command::Grok(args) + | Command::ZCode(args), ) => agent_window_target(args), Some(Command::Session(_) | Command::Blocks(_) | Command::Statusline(_)) => None, } diff --git a/rust/crates/ccusage/src/main.rs b/rust/crates/ccusage/src/main.rs index 4ff5d623d7..2eef7ddcdc 100644 --- a/rust/crates/ccusage/src/main.rs +++ b/rust/crates/ccusage/src/main.rs @@ -48,6 +48,7 @@ fn main() -> Result<()> { Some(Command::Kimi(args)) => adapter::kimi::run(args), Some(Command::OpenClaw(args)) => adapter::openclaw::run(args), Some(Command::Grok(args)) => adapter::grok::run(args), + Some(Command::ZCode(args)) => adapter::zcode::run(args), None => { let args = AgentCommandArgs { shared: cli.shared, @@ -87,7 +88,7 @@ mod tests { #[test] fn agent_commands_are_exposed_by_independent_crates() { - let runs: [fn(AgentCommandArgs) -> Result<()>; 15] = [ + let runs: [fn(AgentCommandArgs) -> Result<()>; 16] = [ ccusage_adapter_amp::run, ccusage_adapter_codebuff::run, ccusage_adapter_codex::run, @@ -103,9 +104,10 @@ mod tests { ccusage_adapter_opencode::run, ccusage_adapter_pi::run, ccusage_adapter_qwen::run, + ccusage_adapter_zcode::run, ]; - assert_eq!(runs.len(), 15); + assert_eq!(runs.len(), 16); } #[test] From 73c7948a04b9df13b639372671a5b42068412f54 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:20:45 +0100 Subject: [PATCH 02/10] fix(zcode): price cache creation at input rates --- docs/guide/configuration.md | 2 +- docs/guide/zcode/index.md | 4 +- docs/index.md | 4 +- rust/Cargo.lock | 1 + rust/adapters/zcode/README.md | 4 +- rust/adapters/zcode/src/loader.rs | 35 +++ rust/adapters/zcode/src/parser.rs | 48 +++- rust/crates/ccusage-adapter-all/Cargo.toml | 1 + ..._all__tests__zcode_fixture_daily_json.snap | 100 +++++++ ...all__tests__zcode_fixture_daily_table.snap | 118 ++++++++ ...ll__tests__zcode_fixture_monthly_json.snap | 81 ++++++ ...l__tests__zcode_fixture_monthly_table.snap | 90 ++++++ ...ll__tests__zcode_fixture_session_json.snap | 77 +++++ ...l__tests__zcode_fixture_session_table.snap | 62 ++++ rust/crates/ccusage-adapter-all/src/tests.rs | 268 +++++++++++++++++- rust/crates/ccusage-cli/README.md | 2 +- rust/crates/ccusage/README.md | 1 + 17 files changed, 887 insertions(+), 11 deletions(-) create mode 100644 rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_daily_json.snap create mode 100644 rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_daily_table.snap create mode 100644 rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_monthly_json.snap create mode 100644 rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_monthly_table.snap create mode 100644 rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_session_json.snap create mode 100644 rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_session_table.snap diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 2297fe2c96..0fcc91fc79 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -110,7 +110,7 @@ For individual developers working on multiple projects: ### Multiple Sources -Configure Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, Grok Build CLI, and ZCode separately with data source namespaces: +Configure Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, and ZCode separately with data source namespaces: ```json // ~/.config/claude/ccusage.json diff --git a/docs/guide/zcode/index.md b/docs/guide/zcode/index.md index bbcb28e102..71c37fc8cf 100644 --- a/docs/guide/zcode/index.md +++ b/docs/guide/zcode/index.md @@ -66,7 +66,9 @@ Z.ai provider, or for legacy rows with no provider and a GLM model. A custom provider UUID is not treated as a billable provider even when its model name matches a generic pricing entry; use an explicit model pricing override when that provider should be estimated. Unpriced models remain at zero cost and use -the standard missing-pricing warning. +the standard missing-pricing warning. Z.ai cache-creation tokens remain in the +displayed cache-creation bucket but are estimated at the model's standard input +rate because they represent new content. ## Configuration diff --git a/docs/index.md b/docs/index.md index cab55d1e1e..4ed543a28c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ layout: home hero: name: ccusage text: Coding (Agent) CLI Usage Analysis - tagline: A fast local CLI for tracking tokens and estimated costs across Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, Grok Build CLI, and ZCode + tagline: A fast local CLI for tracking tokens and estimated costs across Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, and ZCode image: src: /logo.svg alt: ccusage logo @@ -27,7 +27,7 @@ features: link: /guide/getting-started - icon: ๐Ÿ“ title: Local Data Sources - details: Reads local usage logs from Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, Grok Build CLI, and ZCode without uploading your data + details: Reads local usage logs from Claude Code, Codex, OpenCode, Amp, Droid, Codebuff, Hermes Agent, pi-agent, Goose, OpenClaw, Kilo, Kimi, Qwen, GitHub Copilot CLI, Gemini CLI, and ZCode without uploading your data link: /guide/ - icon: ๐Ÿ’ฐ title: Cost Analysis diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7a33e2b9f6..fbbb6c2779 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -177,6 +177,7 @@ dependencies = [ "insta", "serde", "serde_json", + "sqlite", ] [[package]] diff --git a/rust/adapters/zcode/README.md b/rust/adapters/zcode/README.md index 45a00f0dfb..e84362b1ca 100644 --- a/rust/adapters/zcode/README.md +++ b/rust/adapters/zcode/README.md @@ -61,7 +61,9 @@ ZCode records no per-request cost. `display` therefore reports zero cost; `calculate` and `auto` estimate from the shared pricing map. Raw model pricing overrides are honored. Z.ai-qualified candidates are used for explicit Z.ai providers and for GLM model ids from legacy rows without a provider; an unknown -custom provider is not treated as Z.ai. +custom provider is not treated as Z.ai. Z.ai cache-creation tokens remain in +their displayed bucket but are priced at the model's standard input rate because +they represent new content. Unknown custom model ids remain at zero and use the normal missing-pricing warning. No provider billing is inferred from a local provider UUID. diff --git a/rust/adapters/zcode/src/loader.rs b/rust/adapters/zcode/src/loader.rs index f2a0413144..70de3834f1 100644 --- a/rust/adapters/zcode/src/loader.rs +++ b/rust/adapters/zcode/src/loader.rs @@ -352,6 +352,10 @@ mod tests { fn insert_current_usage(path: &Path, id: &str, status: &str) { let db = sqlite::open(path).unwrap(); + insert_current_usage_on_connection(&db, id, status); + } + + fn insert_current_usage_on_connection(db: &sqlite::Connection, id: &str, status: &str) { let mut statement = db .prepare( "INSERT INTO model_usage @@ -367,6 +371,12 @@ mod tests { statement.next().unwrap(); } + fn journal_mode(db: &sqlite::Connection) -> String { + let mut statement = db.prepare("PRAGMA journal_mode").unwrap(); + assert_eq!(statement.next().unwrap(), sqlite::State::Row); + statement.read::(0).unwrap() + } + fn load(root: &Path) -> Vec { let _guard = EnvVarGuard::set(super::super::paths::ZCODE_HOME_ENV, root); let shared = SharedArgs { @@ -423,6 +433,31 @@ mod tests { assert_eq!(entries[0].data.message.usage.cache_read_input_tokens, 25); } + #[test] + fn reads_from_wal_database_without_changing_journal_mode() { + let fixture = fs_fixture!({}); + let _ = fixture.create_dir_all("cli/db"); + let db_path = fixture.path(super::super::paths::ZCODE_DB_RELATIVE_PATH); + create_db(&db_path, false); + + let db = sqlite::open(&db_path).unwrap(); + db.execute("PRAGMA journal_mode = WAL").unwrap(); + db.execute("PRAGMA wal_autocheckpoint = 0").unwrap(); + assert_eq!(journal_mode(&db), "wal"); + db.execute("BEGIN").unwrap(); + db.execute("INSERT INTO session VALUES ('session-1', '/project', '0.16.3')") + .unwrap(); + insert_current_usage_on_connection(&db, "usage-1", "completed"); + db.execute("COMMIT").unwrap(); + assert!(db_path.with_extension("sqlite-wal").is_file()); + + let entries = load(fixture.root()); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].data.message.id.as_deref(), Some("usage-1")); + assert_eq!(journal_mode(&db), "wal"); + } + #[test] fn skips_unrelated_schema_and_deduplicates_multiple_homes() { let first = fs_fixture!({}); diff --git a/rust/adapters/zcode/src/parser.rs b/rust/adapters/zcode/src/parser.rs index 404d3c4081..d3e1172d32 100644 --- a/rust/adapters/zcode/src/parser.rs +++ b/rust/adapters/zcode/src/parser.rs @@ -72,9 +72,24 @@ pub(super) fn row_to_entry( .directory .filter(|directory| !directory.trim().is_empty()) .unwrap_or_else(|| "ZCode".to_string()); - let cost_usage = TokenUsageRaw { - output_tokens: usage.output_tokens.saturating_add(extra_total_tokens), - ..usage + // Z.ai exposes new content as a cache-creation bucket for display, but + // bills that content at the model's standard input rate. + let is_zai = is_zai_provider(row.provider_id.as_deref()) + || (row.provider_id.is_none() && is_zai_model(&raw_model.to_ascii_lowercase())); + let cost_usage = if is_zai { + TokenUsageRaw { + input_tokens: usage + .input_tokens + .saturating_add(usage.cache_creation_input_tokens), + cache_creation_input_tokens: 0, + output_tokens: usage.output_tokens.saturating_add(extra_total_tokens), + ..usage + } + } else { + TokenUsageRaw { + output_tokens: usage.output_tokens.saturating_add(extra_total_tokens), + ..usage + } }; let cost = if mode == CostMode::Display { 0.0 @@ -257,6 +272,33 @@ mod tests { ); } + #[test] + fn prices_zai_cache_creation_at_standard_input_rate_for_glm_52_and_53() { + let pricing = PricingMap::load_embedded(); + let priced = |model: &str| { + let mut row = row(); + row.model_id = model.to_string(); + row_to_entry( + row, + Some(&JiffTimeZone::UTC), + CostMode::Calculate, + &pricing, + &BTreeMap::new(), + ) + .unwrap() + }; + + let glm_52 = priced("GLM-5.2"); + assert_eq!(glm_52.cost, 0.00015549999999999999); + assert_eq!(glm_52.data.message.usage.input_tokens, 60); + assert_eq!(glm_52.data.message.usage.cache_creation_input_tokens, 15); + + let glm_53 = priced("GLM-5.3"); + assert_eq!(glm_53.cost, 0.00015549999999999999); + assert_eq!(glm_53.data.message.usage.input_tokens, 60); + assert_eq!(glm_53.data.message.usage.cache_creation_input_tokens, 15); + } + #[test] fn honors_raw_model_overrides_for_custom_providers() { let mut row = row(); diff --git a/rust/crates/ccusage-adapter-all/Cargo.toml b/rust/crates/ccusage-adapter-all/Cargo.toml index 9e7a424b11..1ff18f494c 100644 --- a/rust/crates/ccusage-adapter-all/Cargo.toml +++ b/rust/crates/ccusage-adapter-all/Cargo.toml @@ -31,3 +31,4 @@ serde_json.workspace = true [dev-dependencies] ccusage-test-support.workspace = true insta.workspace = true +sqlite.workspace = true diff --git a/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_daily_json.snap b/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_daily_json.snap new file mode 100644 index 0000000000..6013271b64 --- /dev/null +++ b/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_daily_json.snap @@ -0,0 +1,100 @@ +--- +source: crates/ccusage-adapter-all/src/tests.rs +expression: "report_json(&daily.rows, AgentReportKind::Daily)" +--- +{ + "daily": [ + { + "agent": "all", + "cacheCreationTokens": 15, + "cacheReadTokens": 25, + "inputTokens": 60, + "metadata": { + "agents": [ + "zcode" + ] + }, + "modelBreakdowns": [ + { + "cacheCreationTokens": 15, + "cacheReadTokens": 25, + "cost": 0.00015549999999999999, + "inputTokens": 60, + "modelName": "GLM-5.2", + "outputTokens": 10 + } + ], + "modelsUsed": [ + "GLM-5.2" + ], + "outputTokens": 10, + "period": "2099-01-02", + "totalCost": 0.00015549999999999999, + "totalTokens": 110 + }, + { + "agent": "all", + "cacheCreationTokens": 30, + "cacheReadTokens": 40, + "inputTokens": 130, + "metadata": { + "agents": [ + "zcode" + ] + }, + "modelBreakdowns": [ + { + "cacheCreationTokens": 30, + "cacheReadTokens": 40, + "cost": 0.0003224, + "inputTokens": 130, + "modelName": "GLM-5.3", + "outputTokens": 20 + } + ], + "modelsUsed": [ + "GLM-5.3" + ], + "outputTokens": 20, + "period": "2099-01-15", + "totalCost": 0.0003224, + "totalTokens": 220 + }, + { + "agent": "all", + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "inputTokens": 40, + "metadata": { + "agents": [ + "zcode" + ] + }, + "modelBreakdowns": [ + { + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "cost": 0.0000806, + "inputTokens": 40, + "modelName": "GLM-5.3", + "outputTokens": 5 + } + ], + "modelsUsed": [ + "GLM-5.3" + ], + "outputTokens": 5, + "period": "2099-02-01", + "totalCost": 0.0000806, + "totalTokens": 55 + } + ], + "totals": { + "cacheCreationTokens": 45, + "cacheReadTokens": 75, + "inputTokens": 230, + "outputTokens": 35, + "totalCost": 0.0005585, + "totalTokens": 385 + } +} diff --git a/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_daily_table.snap b/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_daily_table.snap new file mode 100644 index 0000000000..b24b3e056e --- /dev/null +++ b/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_daily_table.snap @@ -0,0 +1,118 @@ +--- +source: crates/ccusage-adapter-all/src/tests.rs +expression: "serde_json::to_string_pretty(&table_snapshot(&daily.rows,\nAgentReportKind::Daily, &daily.detected_agents,)).unwrap()" +--- +{ + "headers": [ + "Date", + "Agent", + "Models", + "Input", + "Output", + "Cache Create", + "Cache Read", + "Total Tokens", + "Cost (USD)" + ], + "rows": [ + { + "cells": [ + "2099-01-02", + "All", + "", + "60", + "10", + "15", + "25", + "110", + "$0.00" + ], + "kind": "row" + }, + { + "cells": [ + "", + "- ZCode", + "- GLM-5.2", + "60", + "10", + "15", + "25", + "110", + "$0.00" + ], + "kind": "agent-breakdown" + }, + { + "cells": [ + "2099-01-15", + "All", + "", + "130", + "20", + "30", + "40", + "220", + "$0.00" + ], + "kind": "row" + }, + { + "cells": [ + "", + "- ZCode", + "- GLM-5.3", + "130", + "20", + "30", + "40", + "220", + "$0.00" + ], + "kind": "agent-breakdown" + }, + { + "cells": [ + "2099-02-01", + "All", + "", + "40", + "5", + "0", + "10", + "55", + "$0.00" + ], + "kind": "row" + }, + { + "cells": [ + "", + "- ZCode", + "- GLM-5.3", + "40", + "5", + "0", + "10", + "55", + "$0.00" + ], + "kind": "agent-breakdown" + }, + { + "cells": [ + "Total", + "", + "", + "230", + "35", + "45", + "75", + "385", + "$0.00" + ], + "kind": "total" + } + ], + "title": "Coding (Agent) CLI Usage Report - Daily\nDetected: ZCode" +} diff --git a/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_monthly_json.snap b/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_monthly_json.snap new file mode 100644 index 0000000000..59a4dd8900 --- /dev/null +++ b/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_monthly_json.snap @@ -0,0 +1,81 @@ +--- +source: crates/ccusage-adapter-all/src/tests.rs +expression: "report_json(&monthly.rows, AgentReportKind::Monthly)" +--- +{ + "monthly": [ + { + "agent": "all", + "cacheCreationTokens": 45, + "cacheReadTokens": 65, + "inputTokens": 190, + "metadata": { + "agents": [ + "zcode" + ] + }, + "modelBreakdowns": [ + { + "cacheCreationTokens": 30, + "cacheReadTokens": 40, + "cost": 0.0003224, + "inputTokens": 130, + "modelName": "GLM-5.3", + "outputTokens": 20 + }, + { + "cacheCreationTokens": 15, + "cacheReadTokens": 25, + "cost": 0.00015549999999999999, + "inputTokens": 60, + "modelName": "GLM-5.2", + "outputTokens": 10 + } + ], + "modelsUsed": [ + "GLM-5.2", + "GLM-5.3" + ], + "outputTokens": 30, + "period": "2099-01", + "totalCost": 0.00047789999999999996, + "totalTokens": 330 + }, + { + "agent": "all", + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "inputTokens": 40, + "metadata": { + "agents": [ + "zcode" + ] + }, + "modelBreakdowns": [ + { + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "cost": 0.0000806, + "inputTokens": 40, + "modelName": "GLM-5.3", + "outputTokens": 5 + } + ], + "modelsUsed": [ + "GLM-5.3" + ], + "outputTokens": 5, + "period": "2099-02", + "totalCost": 0.0000806, + "totalTokens": 55 + } + ], + "totals": { + "cacheCreationTokens": 45, + "cacheReadTokens": 75, + "inputTokens": 230, + "outputTokens": 35, + "totalCost": 0.0005585, + "totalTokens": 385 + } +} diff --git a/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_monthly_table.snap b/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_monthly_table.snap new file mode 100644 index 0000000000..e3ef61cc7c --- /dev/null +++ b/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_monthly_table.snap @@ -0,0 +1,90 @@ +--- +source: crates/ccusage-adapter-all/src/tests.rs +expression: "serde_json::to_string_pretty(&table_snapshot(&monthly.rows,\nAgentReportKind::Monthly, &monthly.detected_agents,)).unwrap()" +--- +{ + "headers": [ + "Month", + "Agent", + "Models", + "Input", + "Output", + "Cache Create", + "Cache Read", + "Total Tokens", + "Cost (USD)" + ], + "rows": [ + { + "cells": [ + "2099-01", + "All", + "", + "190", + "30", + "45", + "65", + "330", + "$0.00" + ], + "kind": "row" + }, + { + "cells": [ + "", + "- ZCode", + "- GLM-5.2\n- GLM-5.3", + "190", + "30", + "45", + "65", + "330", + "$0.00" + ], + "kind": "agent-breakdown" + }, + { + "cells": [ + "2099-02", + "All", + "", + "40", + "5", + "0", + "10", + "55", + "$0.00" + ], + "kind": "row" + }, + { + "cells": [ + "", + "- ZCode", + "- GLM-5.3", + "40", + "5", + "0", + "10", + "55", + "$0.00" + ], + "kind": "agent-breakdown" + }, + { + "cells": [ + "Total", + "", + "", + "230", + "35", + "45", + "75", + "385", + "$0.00" + ], + "kind": "total" + } + ], + "title": "Coding (Agent) CLI Usage Report - Monthly\nDetected: ZCode" +} diff --git a/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_session_json.snap b/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_session_json.snap new file mode 100644 index 0000000000..b3975eaa3d --- /dev/null +++ b/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_session_json.snap @@ -0,0 +1,77 @@ +--- +source: crates/ccusage-adapter-all/src/tests.rs +expression: "report_json(&session.rows, AgentReportKind::Session)" +--- +{ + "session": [ + { + "agent": "zcode", + "cacheCreationTokens": 45, + "cacheReadTokens": 65, + "inputTokens": 190, + "metadata": { + "lastActivity": "2099-01-15T12:00:00.000Z" + }, + "modelBreakdowns": [ + { + "cacheCreationTokens": 30, + "cacheReadTokens": 40, + "cost": 0.0003224, + "inputTokens": 130, + "modelName": "GLM-5.3", + "outputTokens": 20 + }, + { + "cacheCreationTokens": 15, + "cacheReadTokens": 25, + "cost": 0.00015549999999999999, + "inputTokens": 60, + "modelName": "GLM-5.2", + "outputTokens": 10 + } + ], + "modelsUsed": [ + "GLM-5.2", + "GLM-5.3" + ], + "outputTokens": 30, + "period": "session-a", + "totalCost": 0.00047789999999999996, + "totalTokens": 330 + }, + { + "agent": "zcode", + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "inputTokens": 40, + "metadata": { + "lastActivity": "2099-02-01T00:00:00.000Z" + }, + "modelBreakdowns": [ + { + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "cost": 0.0000806, + "inputTokens": 40, + "modelName": "GLM-5.3", + "outputTokens": 5 + } + ], + "modelsUsed": [ + "GLM-5.3" + ], + "outputTokens": 5, + "period": "session-b", + "totalCost": 0.0000806, + "totalTokens": 55 + } + ], + "totals": { + "cacheCreationTokens": 45, + "cacheReadTokens": 75, + "inputTokens": 230, + "outputTokens": 35, + "totalCost": 0.0005585, + "totalTokens": 385 + } +} diff --git a/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_session_table.snap b/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_session_table.snap new file mode 100644 index 0000000000..acebb801f2 --- /dev/null +++ b/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__zcode_fixture_session_table.snap @@ -0,0 +1,62 @@ +--- +source: crates/ccusage-adapter-all/src/tests.rs +expression: "serde_json::to_string_pretty(&table_snapshot(&session.rows,\nAgentReportKind::Session, &session.detected_agents,)).unwrap()" +--- +{ + "headers": [ + "Session", + "Agent", + "Models", + "Input", + "Output", + "Cache Create", + "Cache Read", + "Total Tokens", + "Cost (USD)" + ], + "rows": [ + { + "cells": [ + "session-a", + "ZCode", + "- GLM-5.2\n- GLM-5.3", + "190", + "30", + "45", + "65", + "330", + "$0.00" + ], + "kind": "row" + }, + { + "cells": [ + "session-b", + "ZCode", + "- GLM-5.3", + "40", + "5", + "0", + "10", + "55", + "$0.00" + ], + "kind": "row" + }, + { + "cells": [ + "Total", + "", + "", + "230", + "35", + "45", + "75", + "385", + "$0.00" + ], + "kind": "total" + } + ], + "title": "Coding (Agent) CLI Usage Report - Session\nDetected: ZCode" +} diff --git a/rust/crates/ccusage-adapter-all/src/tests.rs b/rust/crates/ccusage-adapter-all/src/tests.rs index 43971ebfb5..747a8bb189 100644 --- a/rust/crates/ccusage-adapter-all/src/tests.rs +++ b/rust/crates/ccusage-adapter-all/src/tests.rs @@ -10,12 +10,12 @@ use std::{ time::{Duration, Instant, UNIX_EPOCH}, }; -use serde_json::json; +use serde_json::{Value, json}; use super::*; use crate::{ Align, CodexGroup, CodexModelUsage, ModelBreakdown, PricingMap, - cli::{AgentReportKind, CodexSpeed, SharedArgs}, + cli::{AgentReportKind, CodexSpeed, CostMode, SharedArgs}, model_aliases::set_model_aliases_for_tests, }; use ccusage_adapter_codex::CodexSourceUsage; @@ -654,6 +654,119 @@ fn multi_section_codex_fixture_matches_standalone_sections_for_daily_and_session assert_daily_family_and_session_sections_match_standalone(&shared); } +#[test] +fn zcode_fixture_reports_daily_monthly_session_json_and_table_snapshots() { + let fixture = fs_fixture!({}); + let _ = fixture.create_dir_all("zcode/cli/db"); + let db_path = fixture.path("zcode/cli/db/db.sqlite"); + create_zcode_fixture(&db_path); + let _env = isolated_agent_env( + &fixture, + "ZCODE_HOME", + fixture.path("zcode").into_os_string(), + ); + let mut shared = fixture_shared("20990101", "20990201"); + shared.mode = CostMode::Calculate; + + let daily = load_rows(AgentReportKind::Daily, &shared).unwrap(); + let monthly = load_rows(AgentReportKind::Monthly, &shared).unwrap(); + let session = load_rows(AgentReportKind::Session, &shared).unwrap(); + + assert_eq!(daily.detected_agents, vec!["zcode"]); + assert_eq!(monthly.detected_agents, vec!["zcode"]); + assert_eq!(session.detected_agents, vec!["zcode"]); + assert_eq!(daily.rows.len(), 3); + assert_eq!(daily.rows[0].period, "2099-01-02"); + assert_eq!(daily.rows[0].input_tokens, 60); + assert_eq!(daily.rows[0].output_tokens, 10); + assert_eq!(daily.rows[0].cache_creation_tokens, 15); + assert_eq!(daily.rows[0].cache_read_tokens, 25); + assert_eq!(daily.rows[0].total_tokens, 110); + assert_eq!(daily.rows[0].total_cost, 0.00015549999999999999); + assert_eq!(daily.rows[1].period, "2099-01-15"); + assert_eq!(daily.rows[1].input_tokens, 130); + assert_eq!(daily.rows[1].output_tokens, 20); + assert_eq!(daily.rows[1].cache_creation_tokens, 30); + assert_eq!(daily.rows[1].cache_read_tokens, 40); + assert_eq!(daily.rows[1].total_tokens, 220); + assert_eq!(daily.rows[1].total_cost, 0.0003224); + assert_eq!(daily.rows[2].period, "2099-02-01"); + assert_eq!(daily.rows[2].input_tokens, 40); + assert_eq!(daily.rows[2].output_tokens, 5); + assert_eq!(daily.rows[2].cache_creation_tokens, 0); + assert_eq!(daily.rows[2].cache_read_tokens, 10); + assert_eq!(daily.rows[2].total_tokens, 55); + assert_eq!(daily.rows[2].total_cost, 0.0000806); + assert_eq!(monthly.rows[0].period, "2099-01"); + assert_eq!(monthly.rows[0].total_tokens, 330); + assert_eq!(monthly.rows[0].total_cost, 0.00047789999999999996); + assert_eq!(monthly.rows[1].period, "2099-02"); + assert_eq!(monthly.rows[1].total_tokens, 55); + assert_eq!(monthly.rows[1].total_cost, 0.0000806); + assert_eq!(session.rows[0].period, "session-a"); + assert_eq!(session.rows[0].total_tokens, 330); + assert_eq!(session.rows[0].total_cost, 0.00047789999999999996); + assert_eq!(session.rows[1].period, "session-b"); + assert_eq!(session.rows[1].total_tokens, 55); + assert_eq!(session.rows[1].total_cost, 0.0000806); + + insta::assert_json_snapshot!( + "zcode_fixture_daily_json", + report_json(&daily.rows, AgentReportKind::Daily) + ); + insta::assert_json_snapshot!( + "zcode_fixture_monthly_json", + report_json(&monthly.rows, AgentReportKind::Monthly) + ); + insta::assert_json_snapshot!( + "zcode_fixture_session_json", + report_json(&session.rows, AgentReportKind::Session) + ); + insta::assert_snapshot!( + "zcode_fixture_daily_table", + serde_json::to_string_pretty(&table_snapshot( + &daily.rows, + AgentReportKind::Daily, + &daily.detected_agents, + )) + .unwrap() + ); + insta::assert_snapshot!( + "zcode_fixture_monthly_table", + serde_json::to_string_pretty(&table_snapshot( + &monthly.rows, + AgentReportKind::Monthly, + &monthly.detected_agents, + )) + .unwrap() + ); + insta::assert_snapshot!( + "zcode_fixture_session_table", + serde_json::to_string_pretty(&table_snapshot( + &session.rows, + AgentReportKind::Session, + &session.detected_agents, + )) + .unwrap() + ); +} + +#[test] +fn unified_report_omits_zcode_without_usage_database() { + let fixture = fs_fixture!({}); + let _env = isolated_agent_env( + &fixture, + "ZCODE_HOME", + fixture.path("missing-zcode").into_os_string(), + ); + let shared = fixture_shared("20990101", "20990201"); + + let result = load_rows(AgentReportKind::Daily, &shared).unwrap(); + + assert!(result.rows.is_empty()); + assert!(result.detected_agents.is_empty()); +} + #[test] fn unified_report_filters_codex_paths_before_loading_historical_rows() { let resumed_usage = codex_usage_line("2026-03-15T08:01:00.000Z", "gpt-5", 1_000); @@ -706,6 +819,157 @@ fn fixture_shared(since: &str, until: &str) -> SharedArgs { } } +fn create_zcode_fixture(path: &Path) { + let db = sqlite::open(path).unwrap(); + db.execute( + "CREATE TABLE model_usage ( + id TEXT PRIMARY KEY, session_id TEXT, started_at INTEGER, model_id TEXT, + provider_id TEXT, status TEXT, input_tokens INTEGER, output_tokens INTEGER, + cache_creation_input_tokens INTEGER, cache_read_input_tokens INTEGER, + computed_total_tokens INTEGER + )", + ) + .unwrap(); + db.execute("CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, version TEXT)") + .unwrap(); + db.execute( + "INSERT INTO session VALUES + ('session-a', '/workspace/project-a', '0.16.3'), + ('session-b', '/workspace/project-b', '0.16.3')", + ) + .unwrap(); + insert_zcode_usage( + &db, + ZcodeFixtureUsage { + id: "usage-52", + session_id: "session-a", + timestamp: "2099-01-02T00:00:00.000Z", + model: "GLM-5.2", + input_tokens: 100, + output_tokens: 10, + cache_creation_tokens: 15, + cache_read_tokens: 25, + computed_total_tokens: 110, + }, + ); + insert_zcode_usage( + &db, + ZcodeFixtureUsage { + id: "usage-53", + session_id: "session-a", + timestamp: "2099-01-15T12:00:00.000Z", + model: "GLM-5.3", + input_tokens: 200, + output_tokens: 20, + cache_creation_tokens: 30, + cache_read_tokens: 40, + computed_total_tokens: 220, + }, + ); + insert_zcode_usage( + &db, + ZcodeFixtureUsage { + id: "usage-53-b", + session_id: "session-b", + timestamp: "2099-02-01T00:00:00.000Z", + model: "GLM-5.3", + input_tokens: 50, + output_tokens: 5, + cache_creation_tokens: 0, + cache_read_tokens: 10, + computed_total_tokens: 55, + }, + ); +} + +struct ZcodeFixtureUsage<'a> { + id: &'a str, + session_id: &'a str, + timestamp: &'a str, + model: &'a str, + input_tokens: i64, + output_tokens: i64, + cache_creation_tokens: i64, + cache_read_tokens: i64, + computed_total_tokens: i64, +} + +fn insert_zcode_usage(db: &sqlite::Connection, usage: ZcodeFixtureUsage<'_>) { + let mut statement = db + .prepare( + "INSERT INTO model_usage + (id, session_id, started_at, model_id, provider_id, status, input_tokens, + output_tokens, cache_creation_input_tokens, cache_read_input_tokens, + computed_total_tokens) + VALUES (?1, ?2, ?3, ?4, 'builtin:zai-coding-plan', 'completed', ?5, ?6, ?7, ?8, ?9)", + ) + .unwrap(); + statement.bind((1, usage.id)).unwrap(); + statement.bind((2, usage.session_id)).unwrap(); + statement + .bind((3, parse_ts_timestamp(usage.timestamp).unwrap().as_millis())) + .unwrap(); + statement.bind((4, usage.model)).unwrap(); + statement.bind((5, usage.input_tokens)).unwrap(); + statement.bind((6, usage.output_tokens)).unwrap(); + statement.bind((7, usage.cache_creation_tokens)).unwrap(); + statement.bind((8, usage.cache_read_tokens)).unwrap(); + statement.bind((9, usage.computed_total_tokens)).unwrap(); + statement.next().unwrap(); +} + +fn table_snapshot( + rows: &[AllRow], + kind: AgentReportKind, + detected_agents: &[&'static str], +) -> Value { + let (headers, _) = all_table_columns(kind, false, false); + let mut rendered_rows = Vec::new(); + for row in rows { + rendered_rows.push(json!({ + "kind": "row", + "cells": all_table_row(row, false, false, false), + })); + if let Some(agent_breakdowns) = row.agent_breakdowns.as_ref() { + for breakdown in agent_breakdowns { + rendered_rows.push(json!({ + "kind": "agent-breakdown", + "cells": all_table_row(breakdown, false, true, false), + })); + } + } + } + let totals = report_json(rows, kind)["totals"].clone(); + let total_row = AllRow { + period: "Total".to_string(), + agent: "all", + models_used: Vec::new(), + input_tokens: totals["inputTokens"].as_u64().unwrap(), + output_tokens: totals["outputTokens"].as_u64().unwrap(), + cache_creation_tokens: totals["cacheCreationTokens"].as_u64().unwrap(), + cache_read_tokens: totals["cacheReadTokens"].as_u64().unwrap(), + total_tokens: totals["totalTokens"].as_u64().unwrap(), + total_cost: totals["totalCost"].as_f64().unwrap(), + metadata: None, + metadata_agents: None, + agent_breakdowns: None, + model_breakdowns: Vec::new(), + }; + let mut total_cells = all_table_row(&total_row, false, false, false); + total_cells[1].clear(); + total_cells[2].clear(); + rendered_rows.push(json!({ + "kind": "total", + "cells": total_cells, + })); + + json!({ + "title": all_report_title(kind, rows, detected_agents), + "headers": headers, + "rows": rendered_rows, + }) +} + fn isolated_agent_env( fixture: &ccusage_test_support::Fixture, source_key: &'static str, diff --git a/rust/crates/ccusage-cli/README.md b/rust/crates/ccusage-cli/README.md index 4ca6448462..641e008f6c 100644 --- a/rust/crates/ccusage-cli/README.md +++ b/rust/crates/ccusage-cli/README.md @@ -4,7 +4,7 @@ The plain argument types the runtime shares: `SharedArgs`, the per-command argument structs, and the enums the reports switch on. This crate deliberately has no dependencies, no build script, and no embedded -assets. `ccusage-core` and all 16 adapters depend on it, so anything heavier +assets. `ccusage-core` and all 17 adapters depend on it, so anything heavier would sit on every crate's critical path โ€” that is why the parser, the help renderer, and the help JSON live in `ccusage-cli-parser` instead. diff --git a/rust/crates/ccusage/README.md b/rust/crates/ccusage/README.md index a3a9155154..d20d14bf69 100644 --- a/rust/crates/ccusage/README.md +++ b/rust/crates/ccusage/README.md @@ -36,6 +36,7 @@ source in its own `ccusage-adapter-*` crate. - `ccusage-adapter-opencode` - `ccusage-adapter-pi` - `ccusage-adapter-qwen` +- `ccusage-adapter-zcode` - `ccusage-cli` - `ccusage-cli-parser` - `ccusage-config` From 5540d450da85aa2120af0da0553af1efacad4154 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:59:44 +0100 Subject: [PATCH 03/10] test(zcode): snapshot focused report output Cover daily, monthly, and session JSON and table serialization with report headings and cache buckets. Include ZCODE in the environment diagnostic regex so ZCODE_HOME is visible. --- docs/guide/environment-variables.md | 2 +- rust/Cargo.lock | 1 + rust/adapters/zcode/Cargo.toml | 1 + rust/adapters/zcode/src/report.rs | 220 +++++++++++++++++- ...port__tests__focused_zcode_daily_json.snap | 82 +++++++ ...ort__tests__focused_zcode_daily_table.snap | 71 ++++++ ...rt__tests__focused_zcode_monthly_json.snap | 69 ++++++ ...t__tests__focused_zcode_monthly_table.snap | 58 +++++ ...rt__tests__focused_zcode_session_json.snap | 75 ++++++ ...t__tests__focused_zcode_session_table.snap | 62 +++++ 10 files changed, 639 insertions(+), 2 deletions(-) create mode 100644 rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_daily_json.snap create mode 100644 rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_daily_table.snap create mode 100644 rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_monthly_json.snap create mode 100644 rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_monthly_table.snap create mode 100644 rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_session_json.snap create mode 100644 rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_session_table.snap diff --git a/docs/guide/environment-variables.md b/docs/guide/environment-variables.md index ef115352cd..7d31676a81 100644 --- a/docs/guide/environment-variables.md +++ b/docs/guide/environment-variables.md @@ -214,7 +214,7 @@ To see which environment variables are being used: ```bash # Show all environment variables -env | grep -E "CLAUDE|CODEX|OPENCODE|AMP|DROID|CODEBUFF|HERMES|PI_AGENT|GOOSE|OPENCLAW|KILO|KIMI|QWEN|COPILOT|GEMINI|GROK|CCUSAGE|LOG_LEVEL" +env | grep -E "CLAUDE|CODEX|OPENCODE|AMP|DROID|CODEBUFF|HERMES|PI_AGENT|GOOSE|OPENCLAW|KILO|KIMI|QWEN|COPILOT|GEMINI|GROK|ZCODE|CCUSAGE|LOG_LEVEL" # Debug mode shows environment variable usage LOG_LEVEL=4 ccusage daily --debug diff --git a/rust/Cargo.lock b/rust/Cargo.lock index fbbb6c2779..807da02cb2 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -398,6 +398,7 @@ dependencies = [ "ccusage-adapter-common", "ccusage-core", "ccusage-test-support", + "insta", "jiff", "serde_json", "sqlite", diff --git a/rust/adapters/zcode/Cargo.toml b/rust/adapters/zcode/Cargo.toml index bdb30d9d51..3f3f421954 100644 --- a/rust/adapters/zcode/Cargo.toml +++ b/rust/adapters/zcode/Cargo.toml @@ -13,3 +13,4 @@ sqlite.workspace = true [dev-dependencies] ccusage-test-support.workspace = true +insta.workspace = true diff --git a/rust/adapters/zcode/src/report.rs b/rust/adapters/zcode/src/report.rs index 3a4ec0a92c..f7a7b98e4c 100644 --- a/rust/adapters/zcode/src/report.rs +++ b/rust/adapters/zcode/src/report.rs @@ -73,7 +73,11 @@ fn rows_key(kind: AgentReportKind) -> &'static str { #[cfg(test)] mod tests { use super::*; - use crate::{TimestampMs, TokenUsageRaw, UsageEntry, UsageMessage}; + use crate::{ + TimestampMs, TokenUsageRaw, UsageEntry, UsageMessage, first_column, format_currency, + format_models_multiline, format_number, format_rfc3339_millis, parse_ts_timestamp, + summary_period, + }; use std::sync::Arc; fn entry(session_id: &str, date: &str, millis: i64) -> LoadedEntry { @@ -127,4 +131,218 @@ mod tests { assert_eq!(sessions[0].session_id.as_deref(), Some("session-1")); assert_eq!(sessions[0].project_path.as_deref(), Some("/project")); } + + #[test] + fn snapshots_focused_zcode_cli_reports_for_daily_monthly_and_session() { + let entries = snapshot_entries(); + let daily = summarize_entries(&entries, AgentReportKind::Daily).unwrap(); + let monthly = summarize_entries(&entries, AgentReportKind::Monthly).unwrap(); + let session = summarize_entries(&entries, AgentReportKind::Session).unwrap(); + + insta::assert_json_snapshot!( + "focused_zcode_daily_json", + report_from_rows(&daily, AgentReportKind::Daily) + ); + insta::assert_json_snapshot!( + "focused_zcode_monthly_json", + report_from_rows(&monthly, AgentReportKind::Monthly) + ); + insta::assert_json_snapshot!( + "focused_zcode_session_json", + report_from_rows(&session, AgentReportKind::Session) + ); + insta::assert_snapshot!( + "focused_zcode_daily_table", + serde_json::to_string_pretty(&table_snapshot(&daily, AgentReportKind::Daily)).unwrap() + ); + insta::assert_snapshot!( + "focused_zcode_monthly_table", + serde_json::to_string_pretty(&table_snapshot(&monthly, AgentReportKind::Monthly)) + .unwrap() + ); + insta::assert_snapshot!( + "focused_zcode_session_table", + serde_json::to_string_pretty(&table_snapshot(&session, AgentReportKind::Session)) + .unwrap() + ); + } + + fn snapshot_entries() -> Vec { + [ + ( + "usage-52", + "session-a", + "2099-01-02T00:00:00.000Z", + "GLM-5.2", + 60, + 10, + 15, + 25, + 0.00015549999999999999, + "/workspace/project-a", + ), + ( + "usage-53", + "session-a", + "2099-01-15T12:00:00.000Z", + "GLM-5.3", + 130, + 20, + 30, + 40, + 0.0003224, + "/workspace/project-a", + ), + ( + "usage-53-b", + "session-b", + "2099-02-01T00:00:00.000Z", + "GLM-5.3", + 40, + 5, + 0, + 10, + 0.0000806, + "/workspace/project-b", + ), + ] + .into_iter() + .map( + |( + id, + session_id, + timestamp, + model, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + cost, + project_path, + )| { + let date = timestamp.get(..10).unwrap().to_string(); + let timestamp = parse_ts_timestamp(timestamp).unwrap(); + LoadedEntry { + data: UsageEntry { + session_id: Some(session_id.to_string()), + timestamp: format_rfc3339_millis(timestamp), + version: Some("0.16.3".to_string()), + message: UsageMessage { + usage: TokenUsageRaw { + input_tokens, + output_tokens, + cache_creation_input_tokens: cache_creation_tokens, + cache_read_input_tokens: cache_read_tokens, + speed: None, + cache_creation: None, + }, + model: Some(model.to_string()), + id: Some(id.to_string()), + }, + cost_usd: None, + request_id: None, + is_api_error_message: None, + is_sidechain: None, + }, + timestamp, + date, + project: Arc::from("zcode"), + session_id: Arc::from(session_id), + project_path: Arc::from(project_path), + cost, + extra_total_tokens: 0, + credits: None, + message_count: None, + model: Some(model.to_string()), + usage_limit_reset_time: None, + missing_pricing_model: None, + } + }, + ) + .collect() + } + + fn table_snapshot(rows: &[crate::UsageSummary], kind: AgentReportKind) -> Value { + let show_cache_creation = rows.iter().any(|row| row.cache_creation_tokens > 0); + let include_last_activity = rows.iter().any(|row| row.last_activity.is_some()); + let mut headers = vec![first_column(kind), "Models", "Input", "Output"]; + if show_cache_creation { + headers.push("Cache Create"); + } + headers.extend(["Cache Read", "Total Tokens", "Cost (USD)"]); + if include_last_activity { + headers.push("Last Activity"); + } + + let mut rendered_rows = rows + .iter() + .map(|row| { + json!({ + "kind": "row", + "cells": table_row(row, show_cache_creation, include_last_activity), + }) + }) + .collect::>(); + let totals = report_from_rows(rows, kind)["totals"].clone(); + let mut total_cells = vec![ + "Total".to_string(), + String::new(), + format_number(totals["inputTokens"].as_u64().unwrap()), + format_number(totals["outputTokens"].as_u64().unwrap()), + ]; + if show_cache_creation { + total_cells.push(format_number( + totals["cacheCreationTokens"].as_u64().unwrap(), + )); + } + total_cells.extend([ + format_number(totals["cacheReadTokens"].as_u64().unwrap()), + format_number(totals["totalTokens"].as_u64().unwrap()), + format_currency(totals["totalCost"].as_f64().unwrap()), + ]); + if include_last_activity { + total_cells.push(String::new()); + } + rendered_rows.push(json!({ + "kind": "total", + "cells": total_cells, + })); + + json!({ + "title": "ZCode Token Usage Report", + "headers": headers, + "rows": rendered_rows, + }) + } + + fn table_row( + row: &crate::UsageSummary, + show_cache_creation: bool, + include_last_activity: bool, + ) -> Vec { + let mut cells = vec![ + summary_period(row).to_string(), + format_models_multiline(&row.models_used), + format_number(row.input_tokens), + format_number(row.output_tokens), + ]; + if show_cache_creation { + cells.push(format_number(row.cache_creation_tokens)); + } + cells.extend([ + format_number(row.cache_read_tokens), + format_number(row.total_tokens()), + format_currency(row.total_cost), + ]); + if include_last_activity { + cells.push( + row.last_activity + .as_deref() + .and_then(|value| value.get(..10)) + .unwrap_or_default() + .to_string(), + ); + } + cells + } } diff --git a/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_daily_json.snap b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_daily_json.snap new file mode 100644 index 0000000000..59af67958b --- /dev/null +++ b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_daily_json.snap @@ -0,0 +1,82 @@ +--- +source: adapters/zcode/src/report.rs +expression: "report_from_rows(&daily, AgentReportKind::Daily)" +--- +{ + "daily": [ + { + "cacheCreationTokens": 15, + "cacheReadTokens": 25, + "date": "2099-01-02", + "inputTokens": 60, + "modelBreakdowns": [ + { + "cacheCreationTokens": 15, + "cacheReadTokens": 25, + "cost": 0.00015549999999999999, + "inputTokens": 60, + "modelName": "GLM-5.2", + "outputTokens": 10 + } + ], + "modelsUsed": [ + "GLM-5.2" + ], + "outputTokens": 10, + "totalCost": 0.00015549999999999999, + "totalTokens": 110 + }, + { + "cacheCreationTokens": 30, + "cacheReadTokens": 40, + "date": "2099-01-15", + "inputTokens": 130, + "modelBreakdowns": [ + { + "cacheCreationTokens": 30, + "cacheReadTokens": 40, + "cost": 0.0003224, + "inputTokens": 130, + "modelName": "GLM-5.3", + "outputTokens": 20 + } + ], + "modelsUsed": [ + "GLM-5.3" + ], + "outputTokens": 20, + "totalCost": 0.0003224, + "totalTokens": 220 + }, + { + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "date": "2099-02-01", + "inputTokens": 40, + "modelBreakdowns": [ + { + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "cost": 0.0000806, + "inputTokens": 40, + "modelName": "GLM-5.3", + "outputTokens": 5 + } + ], + "modelsUsed": [ + "GLM-5.3" + ], + "outputTokens": 5, + "totalCost": 0.0000806, + "totalTokens": 55 + } + ], + "totals": { + "cacheCreationTokens": 45, + "cacheReadTokens": 75, + "inputTokens": 230, + "outputTokens": 35, + "totalCost": 0.0005585, + "totalTokens": 385 + } +} diff --git a/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_daily_table.snap b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_daily_table.snap new file mode 100644 index 0000000000..d39a5ffe08 --- /dev/null +++ b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_daily_table.snap @@ -0,0 +1,71 @@ +--- +source: adapters/zcode/src/report.rs +expression: "serde_json::to_string_pretty(&table_snapshot(&daily,\nAgentReportKind::Daily)).unwrap()" +--- +{ + "headers": [ + "Date", + "Models", + "Input", + "Output", + "Cache Create", + "Cache Read", + "Total Tokens", + "Cost (USD)" + ], + "rows": [ + { + "cells": [ + "2099-01-02", + "- GLM-5.2", + "60", + "10", + "15", + "25", + "110", + "$0.00" + ], + "kind": "row" + }, + { + "cells": [ + "2099-01-15", + "- GLM-5.3", + "130", + "20", + "30", + "40", + "220", + "$0.00" + ], + "kind": "row" + }, + { + "cells": [ + "2099-02-01", + "- GLM-5.3", + "40", + "5", + "0", + "10", + "55", + "$0.00" + ], + "kind": "row" + }, + { + "cells": [ + "Total", + "", + "230", + "35", + "45", + "75", + "385", + "$0.00" + ], + "kind": "total" + } + ], + "title": "ZCode Token Usage Report" +} diff --git a/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_monthly_json.snap b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_monthly_json.snap new file mode 100644 index 0000000000..73cf57b86c --- /dev/null +++ b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_monthly_json.snap @@ -0,0 +1,69 @@ +--- +source: adapters/zcode/src/report.rs +expression: "report_from_rows(&monthly, AgentReportKind::Monthly)" +--- +{ + "monthly": [ + { + "cacheCreationTokens": 45, + "cacheReadTokens": 65, + "inputTokens": 190, + "modelBreakdowns": [ + { + "cacheCreationTokens": 30, + "cacheReadTokens": 40, + "cost": 0.0003224, + "inputTokens": 130, + "modelName": "GLM-5.3", + "outputTokens": 20 + }, + { + "cacheCreationTokens": 15, + "cacheReadTokens": 25, + "cost": 0.00015549999999999999, + "inputTokens": 60, + "modelName": "GLM-5.2", + "outputTokens": 10 + } + ], + "modelsUsed": [ + "GLM-5.2", + "GLM-5.3" + ], + "month": "2099-01", + "outputTokens": 30, + "totalCost": 0.00047789999999999996, + "totalTokens": 330 + }, + { + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "inputTokens": 40, + "modelBreakdowns": [ + { + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "cost": 0.0000806, + "inputTokens": 40, + "modelName": "GLM-5.3", + "outputTokens": 5 + } + ], + "modelsUsed": [ + "GLM-5.3" + ], + "month": "2099-02", + "outputTokens": 5, + "totalCost": 0.0000806, + "totalTokens": 55 + } + ], + "totals": { + "cacheCreationTokens": 45, + "cacheReadTokens": 75, + "inputTokens": 230, + "outputTokens": 35, + "totalCost": 0.0005585, + "totalTokens": 385 + } +} diff --git a/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_monthly_table.snap b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_monthly_table.snap new file mode 100644 index 0000000000..68f1073cc5 --- /dev/null +++ b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_monthly_table.snap @@ -0,0 +1,58 @@ +--- +source: adapters/zcode/src/report.rs +expression: "serde_json::to_string_pretty(&table_snapshot(&monthly,\nAgentReportKind::Monthly)).unwrap()" +--- +{ + "headers": [ + "Month", + "Models", + "Input", + "Output", + "Cache Create", + "Cache Read", + "Total Tokens", + "Cost (USD)" + ], + "rows": [ + { + "cells": [ + "2099-01", + "- GLM-5.2\n- GLM-5.3", + "190", + "30", + "45", + "65", + "330", + "$0.00" + ], + "kind": "row" + }, + { + "cells": [ + "2099-02", + "- GLM-5.3", + "40", + "5", + "0", + "10", + "55", + "$0.00" + ], + "kind": "row" + }, + { + "cells": [ + "Total", + "", + "230", + "35", + "45", + "75", + "385", + "$0.00" + ], + "kind": "total" + } + ], + "title": "ZCode Token Usage Report" +} diff --git a/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_session_json.snap b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_session_json.snap new file mode 100644 index 0000000000..545bbb9ff1 --- /dev/null +++ b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_session_json.snap @@ -0,0 +1,75 @@ +--- +source: adapters/zcode/src/report.rs +expression: "report_from_rows(&session, AgentReportKind::Session)" +--- +{ + "sessions": [ + { + "cacheCreationTokens": 45, + "cacheReadTokens": 65, + "firstActivity": "2099-01-02T00:00:00.000Z", + "inputTokens": 190, + "lastActivity": "2099-01-15T12:00:00.000Z", + "modelBreakdowns": [ + { + "cacheCreationTokens": 30, + "cacheReadTokens": 40, + "cost": 0.0003224, + "inputTokens": 130, + "modelName": "GLM-5.3", + "outputTokens": 20 + }, + { + "cacheCreationTokens": 15, + "cacheReadTokens": 25, + "cost": 0.00015549999999999999, + "inputTokens": 60, + "modelName": "GLM-5.2", + "outputTokens": 10 + } + ], + "modelsUsed": [ + "GLM-5.2", + "GLM-5.3" + ], + "outputTokens": 30, + "projectPath": "/workspace/project-a", + "sessionId": "session-a", + "totalCost": 0.00047789999999999996, + "totalTokens": 330 + }, + { + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "firstActivity": "2099-02-01T00:00:00.000Z", + "inputTokens": 40, + "lastActivity": "2099-02-01T00:00:00.000Z", + "modelBreakdowns": [ + { + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "cost": 0.0000806, + "inputTokens": 40, + "modelName": "GLM-5.3", + "outputTokens": 5 + } + ], + "modelsUsed": [ + "GLM-5.3" + ], + "outputTokens": 5, + "projectPath": "/workspace/project-b", + "sessionId": "session-b", + "totalCost": 0.0000806, + "totalTokens": 55 + } + ], + "totals": { + "cacheCreationTokens": 45, + "cacheReadTokens": 75, + "inputTokens": 230, + "outputTokens": 35, + "totalCost": 0.0005585, + "totalTokens": 385 + } +} diff --git a/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_session_table.snap b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_session_table.snap new file mode 100644 index 0000000000..f133f0c7b2 --- /dev/null +++ b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_session_table.snap @@ -0,0 +1,62 @@ +--- +source: adapters/zcode/src/report.rs +expression: "serde_json::to_string_pretty(&table_snapshot(&session,\nAgentReportKind::Session)).unwrap()" +--- +{ + "headers": [ + "Session", + "Models", + "Input", + "Output", + "Cache Create", + "Cache Read", + "Total Tokens", + "Cost (USD)", + "Last Activity" + ], + "rows": [ + { + "cells": [ + "session-a", + "- GLM-5.2\n- GLM-5.3", + "190", + "30", + "45", + "65", + "330", + "$0.00", + "2099-01-15" + ], + "kind": "row" + }, + { + "cells": [ + "session-b", + "- GLM-5.3", + "40", + "5", + "0", + "10", + "55", + "$0.00", + "2099-02-01" + ], + "kind": "row" + }, + { + "cells": [ + "Total", + "", + "230", + "35", + "45", + "75", + "385", + "$0.00", + "" + ], + "kind": "total" + } + ], + "title": "ZCode Token Usage Report" +} From 707bc4acd210c604d88d0c78ffb9426acb55bb2f Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:45:57 +0100 Subject: [PATCH 04/10] test(zcode): snapshot production CLI tables Replace synthetic table-row snapshots with integration snapshots that launch the production binary for daily, monthly, and session reports. Keep JSON summary snapshots on the adapter path while covering the rendered stdout and stderr boundary. --- rust/Cargo.lock | 2 + rust/adapters/zcode/src/report.rs | 105 +------------ ...ort__tests__focused_zcode_daily_table.snap | 71 --------- ...t__tests__focused_zcode_monthly_table.snap | 58 ------- ...t__tests__focused_zcode_session_table.snap | 62 -------- rust/crates/ccusage/Cargo.toml | 2 + .../zcode_cli__zcode_cli_daily_table.snap | 25 +++ .../zcode_cli__zcode_cli_monthly_table.snap | 24 +++ .../zcode_cli__zcode_cli_session_table.snap | 25 +++ rust/crates/ccusage/tests/zcode_cli.rs | 148 ++++++++++++++++++ 10 files changed, 229 insertions(+), 293 deletions(-) delete mode 100644 rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_daily_table.snap delete mode 100644 rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_monthly_table.snap delete mode 100644 rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_session_table.snap create mode 100644 rust/crates/ccusage/tests/snapshots/zcode_cli__zcode_cli_daily_table.snap create mode 100644 rust/crates/ccusage/tests/snapshots/zcode_cli__zcode_cli_monthly_table.snap create mode 100644 rust/crates/ccusage/tests/snapshots/zcode_cli__zcode_cli_session_table.snap create mode 100644 rust/crates/ccusage/tests/zcode_cli.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 807da02cb2..31ced9843f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -143,9 +143,11 @@ dependencies = [ "ccusage-config", "ccusage-core", "ccusage-test-support", + "insta", "mimalloc", "serde", "serde_json", + "sqlite", "ureq", ] diff --git a/rust/adapters/zcode/src/report.rs b/rust/adapters/zcode/src/report.rs index f7a7b98e4c..e07dc7a548 100644 --- a/rust/adapters/zcode/src/report.rs +++ b/rust/adapters/zcode/src/report.rs @@ -74,9 +74,8 @@ fn rows_key(kind: AgentReportKind) -> &'static str { mod tests { use super::*; use crate::{ - TimestampMs, TokenUsageRaw, UsageEntry, UsageMessage, first_column, format_currency, - format_models_multiline, format_number, format_rfc3339_millis, parse_ts_timestamp, - summary_period, + TimestampMs, TokenUsageRaw, UsageEntry, UsageMessage, format_rfc3339_millis, + parse_ts_timestamp, }; use std::sync::Arc; @@ -133,7 +132,7 @@ mod tests { } #[test] - fn snapshots_focused_zcode_cli_reports_for_daily_monthly_and_session() { + fn snapshots_focused_zcode_json_reports_for_daily_monthly_and_session() { let entries = snapshot_entries(); let daily = summarize_entries(&entries, AgentReportKind::Daily).unwrap(); let monthly = summarize_entries(&entries, AgentReportKind::Monthly).unwrap(); @@ -151,20 +150,6 @@ mod tests { "focused_zcode_session_json", report_from_rows(&session, AgentReportKind::Session) ); - insta::assert_snapshot!( - "focused_zcode_daily_table", - serde_json::to_string_pretty(&table_snapshot(&daily, AgentReportKind::Daily)).unwrap() - ); - insta::assert_snapshot!( - "focused_zcode_monthly_table", - serde_json::to_string_pretty(&table_snapshot(&monthly, AgentReportKind::Monthly)) - .unwrap() - ); - insta::assert_snapshot!( - "focused_zcode_session_table", - serde_json::to_string_pretty(&table_snapshot(&session, AgentReportKind::Session)) - .unwrap() - ); } fn snapshot_entries() -> Vec { @@ -261,88 +246,4 @@ mod tests { ) .collect() } - - fn table_snapshot(rows: &[crate::UsageSummary], kind: AgentReportKind) -> Value { - let show_cache_creation = rows.iter().any(|row| row.cache_creation_tokens > 0); - let include_last_activity = rows.iter().any(|row| row.last_activity.is_some()); - let mut headers = vec![first_column(kind), "Models", "Input", "Output"]; - if show_cache_creation { - headers.push("Cache Create"); - } - headers.extend(["Cache Read", "Total Tokens", "Cost (USD)"]); - if include_last_activity { - headers.push("Last Activity"); - } - - let mut rendered_rows = rows - .iter() - .map(|row| { - json!({ - "kind": "row", - "cells": table_row(row, show_cache_creation, include_last_activity), - }) - }) - .collect::>(); - let totals = report_from_rows(rows, kind)["totals"].clone(); - let mut total_cells = vec![ - "Total".to_string(), - String::new(), - format_number(totals["inputTokens"].as_u64().unwrap()), - format_number(totals["outputTokens"].as_u64().unwrap()), - ]; - if show_cache_creation { - total_cells.push(format_number( - totals["cacheCreationTokens"].as_u64().unwrap(), - )); - } - total_cells.extend([ - format_number(totals["cacheReadTokens"].as_u64().unwrap()), - format_number(totals["totalTokens"].as_u64().unwrap()), - format_currency(totals["totalCost"].as_f64().unwrap()), - ]); - if include_last_activity { - total_cells.push(String::new()); - } - rendered_rows.push(json!({ - "kind": "total", - "cells": total_cells, - })); - - json!({ - "title": "ZCode Token Usage Report", - "headers": headers, - "rows": rendered_rows, - }) - } - - fn table_row( - row: &crate::UsageSummary, - show_cache_creation: bool, - include_last_activity: bool, - ) -> Vec { - let mut cells = vec![ - summary_period(row).to_string(), - format_models_multiline(&row.models_used), - format_number(row.input_tokens), - format_number(row.output_tokens), - ]; - if show_cache_creation { - cells.push(format_number(row.cache_creation_tokens)); - } - cells.extend([ - format_number(row.cache_read_tokens), - format_number(row.total_tokens()), - format_currency(row.total_cost), - ]); - if include_last_activity { - cells.push( - row.last_activity - .as_deref() - .and_then(|value| value.get(..10)) - .unwrap_or_default() - .to_string(), - ); - } - cells - } } diff --git a/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_daily_table.snap b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_daily_table.snap deleted file mode 100644 index d39a5ffe08..0000000000 --- a/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_daily_table.snap +++ /dev/null @@ -1,71 +0,0 @@ ---- -source: adapters/zcode/src/report.rs -expression: "serde_json::to_string_pretty(&table_snapshot(&daily,\nAgentReportKind::Daily)).unwrap()" ---- -{ - "headers": [ - "Date", - "Models", - "Input", - "Output", - "Cache Create", - "Cache Read", - "Total Tokens", - "Cost (USD)" - ], - "rows": [ - { - "cells": [ - "2099-01-02", - "- GLM-5.2", - "60", - "10", - "15", - "25", - "110", - "$0.00" - ], - "kind": "row" - }, - { - "cells": [ - "2099-01-15", - "- GLM-5.3", - "130", - "20", - "30", - "40", - "220", - "$0.00" - ], - "kind": "row" - }, - { - "cells": [ - "2099-02-01", - "- GLM-5.3", - "40", - "5", - "0", - "10", - "55", - "$0.00" - ], - "kind": "row" - }, - { - "cells": [ - "Total", - "", - "230", - "35", - "45", - "75", - "385", - "$0.00" - ], - "kind": "total" - } - ], - "title": "ZCode Token Usage Report" -} diff --git a/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_monthly_table.snap b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_monthly_table.snap deleted file mode 100644 index 68f1073cc5..0000000000 --- a/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_monthly_table.snap +++ /dev/null @@ -1,58 +0,0 @@ ---- -source: adapters/zcode/src/report.rs -expression: "serde_json::to_string_pretty(&table_snapshot(&monthly,\nAgentReportKind::Monthly)).unwrap()" ---- -{ - "headers": [ - "Month", - "Models", - "Input", - "Output", - "Cache Create", - "Cache Read", - "Total Tokens", - "Cost (USD)" - ], - "rows": [ - { - "cells": [ - "2099-01", - "- GLM-5.2\n- GLM-5.3", - "190", - "30", - "45", - "65", - "330", - "$0.00" - ], - "kind": "row" - }, - { - "cells": [ - "2099-02", - "- GLM-5.3", - "40", - "5", - "0", - "10", - "55", - "$0.00" - ], - "kind": "row" - }, - { - "cells": [ - "Total", - "", - "230", - "35", - "45", - "75", - "385", - "$0.00" - ], - "kind": "total" - } - ], - "title": "ZCode Token Usage Report" -} diff --git a/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_session_table.snap b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_session_table.snap deleted file mode 100644 index f133f0c7b2..0000000000 --- a/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_session_table.snap +++ /dev/null @@ -1,62 +0,0 @@ ---- -source: adapters/zcode/src/report.rs -expression: "serde_json::to_string_pretty(&table_snapshot(&session,\nAgentReportKind::Session)).unwrap()" ---- -{ - "headers": [ - "Session", - "Models", - "Input", - "Output", - "Cache Create", - "Cache Read", - "Total Tokens", - "Cost (USD)", - "Last Activity" - ], - "rows": [ - { - "cells": [ - "session-a", - "- GLM-5.2\n- GLM-5.3", - "190", - "30", - "45", - "65", - "330", - "$0.00", - "2099-01-15" - ], - "kind": "row" - }, - { - "cells": [ - "session-b", - "- GLM-5.3", - "40", - "5", - "0", - "10", - "55", - "$0.00", - "2099-02-01" - ], - "kind": "row" - }, - { - "cells": [ - "Total", - "", - "230", - "35", - "45", - "75", - "385", - "$0.00", - "" - ], - "kind": "total" - } - ], - "title": "ZCode Token Usage Report" -} diff --git a/rust/crates/ccusage/Cargo.toml b/rust/crates/ccusage/Cargo.toml index 7d663fefb8..2e14f229b5 100644 --- a/rust/crates/ccusage/Cargo.toml +++ b/rust/crates/ccusage/Cargo.toml @@ -47,6 +47,8 @@ serde_json.workspace = true [dev-dependencies] ccusage-test-support.workspace = true +insta.workspace = true +sqlite.workspace = true # mimalloc measurably beats the musl allocator on the static Linux build's # allocation-heavy JSONL parsing. diff --git a/rust/crates/ccusage/tests/snapshots/zcode_cli__zcode_cli_daily_table.snap b/rust/crates/ccusage/tests/snapshots/zcode_cli__zcode_cli_daily_table.snap new file mode 100644 index 0000000000..45aee6952c --- /dev/null +++ b/rust/crates/ccusage/tests/snapshots/zcode_cli__zcode_cli_daily_table.snap @@ -0,0 +1,25 @@ +--- +source: crates/ccusage/tests/zcode_cli.rs +expression: "format!(\"stdout:\\n{stdout}\\nstderr:\\n{stderr}\")" +--- +stdout: + +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ +โ”‚ ZCode Token Usage Report โ”‚ +โ”‚ โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Date โ”‚ Models โ”‚ Input โ”‚ Output โ”‚ Cache Create โ”‚ Cache Read โ”‚ Total Tokens โ”‚ Cost (USD) โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ 2099-01-02 โ”‚ - GLM-5.2 โ”‚ 60 โ”‚ 10 โ”‚ 15 โ”‚ 25 โ”‚ 110 โ”‚ $0.00 โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ 2099-01-15 โ”‚ - GLM-5.3 โ”‚ 130 โ”‚ 20 โ”‚ 30 โ”‚ 40 โ”‚ 220 โ”‚ $0.00 โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ 2099-02-01 โ”‚ - GLM-5.3 โ”‚ 40 โ”‚ 5 โ”‚ 0 โ”‚ 10 โ”‚ 55 โ”‚ $0.00 โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Total โ”‚ โ”‚ 230 โ”‚ 35 โ”‚ 45 โ”‚ 75 โ”‚ 385 โ”‚ $0.00 โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +stderr: diff --git a/rust/crates/ccusage/tests/snapshots/zcode_cli__zcode_cli_monthly_table.snap b/rust/crates/ccusage/tests/snapshots/zcode_cli__zcode_cli_monthly_table.snap new file mode 100644 index 0000000000..6cfecd235f --- /dev/null +++ b/rust/crates/ccusage/tests/snapshots/zcode_cli__zcode_cli_monthly_table.snap @@ -0,0 +1,24 @@ +--- +source: crates/ccusage/tests/zcode_cli.rs +expression: "format!(\"stdout:\\n{stdout}\\nstderr:\\n{stderr}\")" +--- +stdout: + +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ +โ”‚ ZCode Token Usage Report โ”‚ +โ”‚ โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Month โ”‚ Models โ”‚ Input โ”‚ Output โ”‚ Cache Create โ”‚ Cache Read โ”‚ Total Tokens โ”‚ Cost (USD) โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ 2099-01 โ”‚ - GLM-5.2 โ”‚ 190 โ”‚ 30 โ”‚ 45 โ”‚ 65 โ”‚ 330 โ”‚ $0.00 โ”‚ +โ”‚ โ”‚ - GLM-5.3 โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ 2099-02 โ”‚ - GLM-5.3 โ”‚ 40 โ”‚ 5 โ”‚ 0 โ”‚ 10 โ”‚ 55 โ”‚ $0.00 โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Total โ”‚ โ”‚ 230 โ”‚ 35 โ”‚ 45 โ”‚ 75 โ”‚ 385 โ”‚ $0.00 โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +stderr: diff --git a/rust/crates/ccusage/tests/snapshots/zcode_cli__zcode_cli_session_table.snap b/rust/crates/ccusage/tests/snapshots/zcode_cli__zcode_cli_session_table.snap new file mode 100644 index 0000000000..b33331aada --- /dev/null +++ b/rust/crates/ccusage/tests/snapshots/zcode_cli__zcode_cli_session_table.snap @@ -0,0 +1,25 @@ +--- +source: crates/ccusage/tests/zcode_cli.rs +expression: "format!(\"stdout:\\n{stdout}\\nstderr:\\n{stderr}\")" +--- +stdout: + +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ +โ”‚ ZCode Token Usage Report โ”‚ +โ”‚ โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Session โ”‚ Models โ”‚ Input โ”‚ Output โ”‚ Cache โ”‚ Cache Read โ”‚ Total โ”‚ Cost (USD) โ”‚ Last โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ Create โ”‚ โ”‚ Tokens โ”‚ โ”‚ Activity โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ sessionโ€ฆ โ”‚ - GLM-5.2 โ”‚ 190 โ”‚ 30 โ”‚ 45 โ”‚ 65 โ”‚ 330 โ”‚ $0.00 โ”‚ 2099-01-15 โ”‚ +โ”‚ โ”‚ - GLM-5.3 โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ sessionโ€ฆ โ”‚ - GLM-5.3 โ”‚ 40 โ”‚ 5 โ”‚ 0 โ”‚ 10 โ”‚ 55 โ”‚ $0.00 โ”‚ 2099-02-01 โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Total โ”‚ โ”‚ 230 โ”‚ 35 โ”‚ 45 โ”‚ 75 โ”‚ 385 โ”‚ $0.00 โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +stderr: diff --git a/rust/crates/ccusage/tests/zcode_cli.rs b/rust/crates/ccusage/tests/zcode_cli.rs new file mode 100644 index 0000000000..bf7f16032a --- /dev/null +++ b/rust/crates/ccusage/tests/zcode_cli.rs @@ -0,0 +1,148 @@ +use std::path::Path; + +use ccusage_core::parse_ts_timestamp; +use ccusage_test_support::Fixture; + +#[test] +fn zcode_cli_tables_snapshot_production_stdout_and_stderr() { + let fixture = Fixture::new(); + let _ = fixture.create_dir_all("zcode/cli/db"); + create_zcode_fixture(&fixture.path("zcode/cli/db/db.sqlite")); + + for kind in ["daily", "monthly", "session"] { + let output = std::process::Command::new(env!("CARGO_BIN_EXE_ccusage")) + .env_clear() + .env("HOME", fixture.path("home")) + .env("USERPROFILE", fixture.path("userprofile")) + .env("XDG_CONFIG_HOME", fixture.path("xdg-config")) + .env("ZCODE_HOME", fixture.path("zcode")) + .args([ + "zcode", + kind, + "--since", + "20990101", + "--until", + "20990201", + "--mode", + "calculate", + "--offline", + "--no-color", + "--timezone", + "UTC", + ]) + .output() + .expect("failed to run ccusage"); + + assert!( + output.status.success(), + "ccusage zcode {kind} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("CLI stdout was not UTF-8"); + let stderr = String::from_utf8(output.stderr).expect("CLI stderr was not UTF-8"); + insta::assert_snapshot!( + format!("zcode_cli_{kind}_table"), + format!("stdout:\n{stdout}\nstderr:\n{stderr}") + ); + } +} + +fn create_zcode_fixture(path: &Path) { + let db = sqlite::open(path).unwrap(); + db.execute( + "CREATE TABLE model_usage ( + id TEXT PRIMARY KEY, session_id TEXT, started_at INTEGER, model_id TEXT, + provider_id TEXT, status TEXT, input_tokens INTEGER, output_tokens INTEGER, + cache_creation_input_tokens INTEGER, cache_read_input_tokens INTEGER, + computed_total_tokens INTEGER + )", + ) + .unwrap(); + db.execute("CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, version TEXT)") + .unwrap(); + db.execute( + "INSERT INTO session VALUES + ('session-a', '/workspace/project-a', '0.16.3'), + ('session-b', '/workspace/project-b', '0.16.3')", + ) + .unwrap(); + insert_zcode_usage( + &db, + ZcodeFixtureUsage { + id: "usage-52", + session_id: "session-a", + timestamp: "2099-01-02T00:00:00.000Z", + model: "GLM-5.2", + input_tokens: 100, + output_tokens: 10, + cache_creation_tokens: 15, + cache_read_tokens: 25, + computed_total_tokens: 110, + }, + ); + insert_zcode_usage( + &db, + ZcodeFixtureUsage { + id: "usage-53", + session_id: "session-a", + timestamp: "2099-01-15T12:00:00.000Z", + model: "GLM-5.3", + input_tokens: 200, + output_tokens: 20, + cache_creation_tokens: 30, + cache_read_tokens: 40, + computed_total_tokens: 220, + }, + ); + insert_zcode_usage( + &db, + ZcodeFixtureUsage { + id: "usage-53-b", + session_id: "session-b", + timestamp: "2099-02-01T00:00:00.000Z", + model: "GLM-5.3", + input_tokens: 50, + output_tokens: 5, + cache_creation_tokens: 0, + cache_read_tokens: 10, + computed_total_tokens: 55, + }, + ); +} + +struct ZcodeFixtureUsage<'a> { + id: &'a str, + session_id: &'a str, + timestamp: &'a str, + model: &'a str, + input_tokens: i64, + output_tokens: i64, + cache_creation_tokens: i64, + cache_read_tokens: i64, + computed_total_tokens: i64, +} + +fn insert_zcode_usage(db: &sqlite::Connection, usage: ZcodeFixtureUsage<'_>) { + let mut statement = db + .prepare( + "INSERT INTO model_usage + (id, session_id, started_at, model_id, provider_id, status, input_tokens, + output_tokens, cache_creation_input_tokens, cache_read_input_tokens, + computed_total_tokens) + VALUES (?1, ?2, ?3, ?4, 'builtin:zai-coding-plan', 'completed', ?5, ?6, ?7, ?8, ?9)", + ) + .unwrap(); + statement.bind((1, usage.id)).unwrap(); + statement.bind((2, usage.session_id)).unwrap(); + statement + .bind((3, parse_ts_timestamp(usage.timestamp).unwrap().as_millis())) + .unwrap(); + statement.bind((4, usage.model)).unwrap(); + statement.bind((5, usage.input_tokens)).unwrap(); + statement.bind((6, usage.output_tokens)).unwrap(); + statement.bind((7, usage.cache_creation_tokens)).unwrap(); + statement.bind((8, usage.cache_read_tokens)).unwrap(); + statement.bind((9, usage.computed_total_tokens)).unwrap(); + statement.next().unwrap(); +} From c9bb0697bf134fa5349a8b133fe1bfac65899fef Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:08:20 +0100 Subject: [PATCH 05/10] fix(zcode): preserve provider-aware pricing Keep optional provider and session-version columns in their native SQLite types so bundled statement decoding preserves non-null values. Route the legacy BigModel coding-plan provider through Z.ai's provider-qualified GLM pricing path. Assert the loader's message-ID invariant while covering opaque-provider missing pricing. Co-authored-by: axisrow --- rust/adapters/zcode/src/loader.rs | 46 +++++++++++++++++++++++++------ rust/adapters/zcode/src/parser.rs | 24 +++++++++++++++- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/rust/adapters/zcode/src/loader.rs b/rust/adapters/zcode/src/loader.rs index 70de3834f1..86dd6931dc 100644 --- a/rust/adapters/zcode/src/loader.rs +++ b/rust/adapters/zcode/src/loader.rs @@ -66,10 +66,12 @@ fn load_entries_inner(shared: &SharedArgs, pricing: &PricingMap) -> Result(0).unwrap() } - fn load(root: &Path) -> Vec { + fn load_with_mode(root: &Path, mode: CostMode) -> Vec { let _guard = EnvVarGuard::set(super::super::paths::ZCODE_HOME_ENV, root); let shared = SharedArgs { - mode: CostMode::Display, + mode, timezone: Some("UTC".to_string()), ..SharedArgs::default() }; load_entries(&shared, &PricingMap::load_embedded()).unwrap() } + fn load(root: &Path) -> Vec { + load_with_mode(root, CostMode::Display) + } + #[test] fn loads_completed_rows_with_millisecond_precision() { let fixture = fs_fixture!({}); @@ -407,6 +413,30 @@ mod tests { assert_eq!(entries[0].project_path.as_ref(), "/project"); } + #[test] + fn preserves_opaque_provider_for_loader_pricing() { + let fixture = fs_fixture!({}); + let _ = fixture.create_dir_all("cli/db"); + let db_path = fixture.path(super::super::paths::ZCODE_DB_RELATIVE_PATH); + create_db(&db_path, false); + let db = sqlite::open(&db_path).unwrap(); + db.execute("INSERT INTO session VALUES ('session-1', '/project', '0.16.3')") + .unwrap(); + db.execute( + "INSERT INTO model_usage + VALUES ('usage-opaque-provider', 'session-1', 1786909042666, 'GLM-5.3', + '847d13c9-0568-4f2f-818e-8bd498e5d920', 'completed', 100, 10, 15, 25, 110)", + ) + .unwrap(); + drop(db); + + let entries = load_with_mode(fixture.root(), CostMode::Calculate); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].cost, 0.0); + assert_eq!(entries[0].missing_pricing_model.as_deref(), Some("GLM-5.3")); + } + #[test] fn accepts_legacy_schema_without_optional_columns() { let fixture = fs_fixture!({}); diff --git a/rust/adapters/zcode/src/parser.rs b/rust/adapters/zcode/src/parser.rs index d3e1172d32..078ae25abe 100644 --- a/rust/adapters/zcode/src/parser.rs +++ b/rust/adapters/zcode/src/parser.rs @@ -187,7 +187,11 @@ fn is_zai_provider(provider_id: Option<&str>) -> bool { provider_id.is_some_and(|provider| { matches!( provider.trim().to_ascii_lowercase().as_str(), - "zai" | "z.ai" | "zai-coding-plan" | "builtin:zai-coding-plan" + "zai" + | "z.ai" + | "zai-coding-plan" + | "builtin:zai-coding-plan" + | "builtin:bigmodel-coding-plan" ) }) } @@ -272,6 +276,24 @@ mod tests { ); } + #[test] + fn prices_legacy_bigmodel_provider_with_provider_qualified_glm_pricing() { + let mut row = row(); + row.provider_id = Some("builtin:bigmodel-coding-plan".to_string()); + + let entry = row_to_entry( + row, + Some(&JiffTimeZone::UTC), + CostMode::Calculate, + &PricingMap::load_embedded(), + &BTreeMap::new(), + ) + .unwrap(); + + assert_eq!(entry.cost, 0.00015549999999999999); + assert!(entry.missing_pricing_model.is_none()); + } + #[test] fn prices_zai_cache_creation_at_standard_input_rate_for_glm_52_and_53() { let pricing = PricingMap::load_embedded(); From 6307e5743a3ed711bd2244f83370b46da73ed819 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:08:59 +0100 Subject: [PATCH 06/10] fix(core): saturate token total arithmetic Use saturating addition for derived cache-creation and total token counts before applying fallback values. This keeps unusually large database counters from panicking while preserving the existing missing-output and extra-token behavior, with a regression covering direct and cache-breakdown counters. --- rust/crates/ccusage-core/src/types.rs | 3 ++- rust/crates/ccusage-core/src/utils.rs | 31 +++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/rust/crates/ccusage-core/src/types.rs b/rust/crates/ccusage-core/src/types.rs index ce23b6968c..e81aa6584f 100644 --- a/rust/crates/ccusage-core/src/types.rs +++ b/rust/crates/ccusage-core/src/types.rs @@ -41,7 +41,8 @@ pub struct TokenUsageRaw { impl TokenUsageRaw { pub fn cache_creation_token_count(&self) -> u64 { if let Some(b) = &self.cache_creation { - b.ephemeral_5m_input_tokens + b.ephemeral_1h_input_tokens + b.ephemeral_5m_input_tokens + .saturating_add(b.ephemeral_1h_input_tokens) } else { self.cache_creation_input_tokens } diff --git a/rust/crates/ccusage-core/src/utils.rs b/rust/crates/ccusage-core/src/utils.rs index fa2e029e9b..01915e77d2 100644 --- a/rust/crates/ccusage-core/src/utils.rs +++ b/rust/crates/ccusage-core/src/utils.rs @@ -12,10 +12,11 @@ pub fn non_empty_json_string(value: Option<&Value>) -> Option { } pub fn total_usage_tokens(usage: TokenUsageRaw) -> u64 { - usage.input_tokens - + usage.output_tokens - + usage.cache_creation_token_count() - + usage.cache_read_input_tokens + usage + .input_tokens + .saturating_add(usage.output_tokens) + .saturating_add(usage.cache_creation_token_count()) + .saturating_add(usage.cache_read_input_tokens) } pub fn apply_total_token_fallback( @@ -79,4 +80,26 @@ mod tests { assert_eq!(usage.output_tokens, 50); assert_eq!(extra_total_tokens, 25); } + + #[test] + fn saturates_total_token_fallback_for_huge_counters() { + let (usage, extra_total_tokens) = apply_total_token_fallback( + TokenUsageRaw { + input_tokens: u64::MAX, + output_tokens: u64::MAX, + cache_creation_input_tokens: 0, + cache_read_input_tokens: u64::MAX, + speed: None, + cache_creation: Some(crate::CacheCreationRaw { + ephemeral_5m_input_tokens: u64::MAX, + ephemeral_1h_input_tokens: u64::MAX, + }), + }, + u64::MAX, + u64::MAX, + ); + + assert_eq!(total_usage_tokens(usage), u64::MAX); + assert_eq!(extra_total_tokens, u64::MAX); + } } From da69a7c556293a2566641239c79554b6614dd6e4 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:09:34 +0100 Subject: [PATCH 07/10] test(zcode): snapshot weekly reports Exercise the focused JSON report through the weekly aggregation path and commit its complete JSON snapshot alongside the existing daily, monthly, and session coverage. Co-authored-by: axisrow --- rust/adapters/zcode/src/report.rs | 5 ++ ...ort__tests__focused_zcode_weekly_json.snap | 83 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_weekly_json.snap diff --git a/rust/adapters/zcode/src/report.rs b/rust/adapters/zcode/src/report.rs index e07dc7a548..91906c83c9 100644 --- a/rust/adapters/zcode/src/report.rs +++ b/rust/adapters/zcode/src/report.rs @@ -136,6 +136,7 @@ mod tests { let entries = snapshot_entries(); let daily = summarize_entries(&entries, AgentReportKind::Daily).unwrap(); let monthly = summarize_entries(&entries, AgentReportKind::Monthly).unwrap(); + let weekly = summarize_entries(&entries, AgentReportKind::Weekly).unwrap(); let session = summarize_entries(&entries, AgentReportKind::Session).unwrap(); insta::assert_json_snapshot!( @@ -146,6 +147,10 @@ mod tests { "focused_zcode_monthly_json", report_from_rows(&monthly, AgentReportKind::Monthly) ); + insta::assert_json_snapshot!( + "focused_zcode_weekly_json", + report_from_rows(&weekly, AgentReportKind::Weekly) + ); insta::assert_json_snapshot!( "focused_zcode_session_json", report_from_rows(&session, AgentReportKind::Session) diff --git a/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_weekly_json.snap b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_weekly_json.snap new file mode 100644 index 0000000000..47f381f5b8 --- /dev/null +++ b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_weekly_json.snap @@ -0,0 +1,83 @@ +--- +source: adapters/zcode/src/report.rs +assertion_line: 150 +expression: "report_from_rows(&weekly, AgentReportKind::Weekly)" +--- +{ + "totals": { + "cacheCreationTokens": 45, + "cacheReadTokens": 75, + "inputTokens": 230, + "outputTokens": 35, + "totalCost": 0.0005585, + "totalTokens": 385 + }, + "weekly": [ + { + "cacheCreationTokens": 15, + "cacheReadTokens": 25, + "inputTokens": 60, + "modelBreakdowns": [ + { + "cacheCreationTokens": 15, + "cacheReadTokens": 25, + "cost": 0.00015549999999999999, + "inputTokens": 60, + "modelName": "GLM-5.2", + "outputTokens": 10 + } + ], + "modelsUsed": [ + "GLM-5.2" + ], + "outputTokens": 10, + "totalCost": 0.00015549999999999999, + "totalTokens": 110, + "week": "2098-12-28" + }, + { + "cacheCreationTokens": 30, + "cacheReadTokens": 40, + "inputTokens": 130, + "modelBreakdowns": [ + { + "cacheCreationTokens": 30, + "cacheReadTokens": 40, + "cost": 0.0003224, + "inputTokens": 130, + "modelName": "GLM-5.3", + "outputTokens": 20 + } + ], + "modelsUsed": [ + "GLM-5.3" + ], + "outputTokens": 20, + "totalCost": 0.0003224, + "totalTokens": 220, + "week": "2099-01-11" + }, + { + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "inputTokens": 40, + "modelBreakdowns": [ + { + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "cost": 0.0000806, + "inputTokens": 40, + "modelName": "GLM-5.3", + "outputTokens": 5 + } + ], + "modelsUsed": [ + "GLM-5.3" + ], + "outputTokens": 5, + "totalCost": 0.0000806, + "totalTokens": 55, + "week": "2099-02-01" + } + ] +} From e70d1e977e0019a6ea80069b20ed8dd889105a81 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:10:13 +0100 Subject: [PATCH 08/10] test(zcode): share SQLite fixtures Move the identical ZCode schema and usage rows into ccusage-test-support so the adapter-all and production CLI tests exercise one fixture definition. Keep the fixture's database dependencies in the support crate and leave its internal row builder private. Co-authored-by: axisrow --- rust/Cargo.lock | 2 + rust/crates/ccusage-adapter-all/src/tests.rs | 103 +--------------- rust/crates/ccusage-test-support/Cargo.toml | 2 + rust/crates/ccusage-test-support/src/lib.rs | 2 + rust/crates/ccusage-test-support/src/zcode.rs | 113 ++++++++++++++++++ rust/crates/ccusage/tests/zcode_cli.rs | 106 +--------------- 6 files changed, 123 insertions(+), 205 deletions(-) create mode 100644 rust/crates/ccusage-test-support/src/zcode.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 31ced9843f..bc38a78dbb 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -467,6 +467,8 @@ name = "ccusage-test-support" version = "0.0.0" dependencies = [ "assert_fs", + "jiff", + "sqlite", ] [[package]] diff --git a/rust/crates/ccusage-adapter-all/src/tests.rs b/rust/crates/ccusage-adapter-all/src/tests.rs index 747a8bb189..05653e9520 100644 --- a/rust/crates/ccusage-adapter-all/src/tests.rs +++ b/rust/crates/ccusage-adapter-all/src/tests.rs @@ -19,7 +19,7 @@ use crate::{ model_aliases::set_model_aliases_for_tests, }; use ccusage_adapter_codex::CodexSourceUsage; -use ccusage_test_support::{EnvVarsGuard, fs_fixture}; +use ccusage_test_support::{EnvVarsGuard, fs_fixture, zcode::create_fixture}; fn test_agent_rows(agent: &'static str) -> AgentRows { AgentRows { @@ -659,7 +659,7 @@ fn zcode_fixture_reports_daily_monthly_session_json_and_table_snapshots() { let fixture = fs_fixture!({}); let _ = fixture.create_dir_all("zcode/cli/db"); let db_path = fixture.path("zcode/cli/db/db.sqlite"); - create_zcode_fixture(&db_path); + create_fixture(db_path); let _env = isolated_agent_env( &fixture, "ZCODE_HOME", @@ -819,105 +819,6 @@ fn fixture_shared(since: &str, until: &str) -> SharedArgs { } } -fn create_zcode_fixture(path: &Path) { - let db = sqlite::open(path).unwrap(); - db.execute( - "CREATE TABLE model_usage ( - id TEXT PRIMARY KEY, session_id TEXT, started_at INTEGER, model_id TEXT, - provider_id TEXT, status TEXT, input_tokens INTEGER, output_tokens INTEGER, - cache_creation_input_tokens INTEGER, cache_read_input_tokens INTEGER, - computed_total_tokens INTEGER - )", - ) - .unwrap(); - db.execute("CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, version TEXT)") - .unwrap(); - db.execute( - "INSERT INTO session VALUES - ('session-a', '/workspace/project-a', '0.16.3'), - ('session-b', '/workspace/project-b', '0.16.3')", - ) - .unwrap(); - insert_zcode_usage( - &db, - ZcodeFixtureUsage { - id: "usage-52", - session_id: "session-a", - timestamp: "2099-01-02T00:00:00.000Z", - model: "GLM-5.2", - input_tokens: 100, - output_tokens: 10, - cache_creation_tokens: 15, - cache_read_tokens: 25, - computed_total_tokens: 110, - }, - ); - insert_zcode_usage( - &db, - ZcodeFixtureUsage { - id: "usage-53", - session_id: "session-a", - timestamp: "2099-01-15T12:00:00.000Z", - model: "GLM-5.3", - input_tokens: 200, - output_tokens: 20, - cache_creation_tokens: 30, - cache_read_tokens: 40, - computed_total_tokens: 220, - }, - ); - insert_zcode_usage( - &db, - ZcodeFixtureUsage { - id: "usage-53-b", - session_id: "session-b", - timestamp: "2099-02-01T00:00:00.000Z", - model: "GLM-5.3", - input_tokens: 50, - output_tokens: 5, - cache_creation_tokens: 0, - cache_read_tokens: 10, - computed_total_tokens: 55, - }, - ); -} - -struct ZcodeFixtureUsage<'a> { - id: &'a str, - session_id: &'a str, - timestamp: &'a str, - model: &'a str, - input_tokens: i64, - output_tokens: i64, - cache_creation_tokens: i64, - cache_read_tokens: i64, - computed_total_tokens: i64, -} - -fn insert_zcode_usage(db: &sqlite::Connection, usage: ZcodeFixtureUsage<'_>) { - let mut statement = db - .prepare( - "INSERT INTO model_usage - (id, session_id, started_at, model_id, provider_id, status, input_tokens, - output_tokens, cache_creation_input_tokens, cache_read_input_tokens, - computed_total_tokens) - VALUES (?1, ?2, ?3, ?4, 'builtin:zai-coding-plan', 'completed', ?5, ?6, ?7, ?8, ?9)", - ) - .unwrap(); - statement.bind((1, usage.id)).unwrap(); - statement.bind((2, usage.session_id)).unwrap(); - statement - .bind((3, parse_ts_timestamp(usage.timestamp).unwrap().as_millis())) - .unwrap(); - statement.bind((4, usage.model)).unwrap(); - statement.bind((5, usage.input_tokens)).unwrap(); - statement.bind((6, usage.output_tokens)).unwrap(); - statement.bind((7, usage.cache_creation_tokens)).unwrap(); - statement.bind((8, usage.cache_read_tokens)).unwrap(); - statement.bind((9, usage.computed_total_tokens)).unwrap(); - statement.next().unwrap(); -} - fn table_snapshot( rows: &[AllRow], kind: AgentReportKind, diff --git a/rust/crates/ccusage-test-support/Cargo.toml b/rust/crates/ccusage-test-support/Cargo.toml index dd126a84d9..a7d1d3a8d8 100644 --- a/rust/crates/ccusage-test-support/Cargo.toml +++ b/rust/crates/ccusage-test-support/Cargo.toml @@ -6,3 +6,5 @@ publish.workspace = true [dependencies] assert_fs.workspace = true +jiff.workspace = true +sqlite.workspace = true diff --git a/rust/crates/ccusage-test-support/src/lib.rs b/rust/crates/ccusage-test-support/src/lib.rs index 8972fbc947..5a1849e314 100644 --- a/rust/crates/ccusage-test-support/src/lib.rs +++ b/rust/crates/ccusage-test-support/src/lib.rs @@ -10,6 +10,8 @@ use assert_fs::{ fixture::{ChildPath, FileWriteStr, PathChild, PathCreateDir}, }; +pub mod zcode; + static ENV_LOCK: Mutex<()> = Mutex::new(()); fn env_lock() -> MutexGuard<'static, ()> { diff --git a/rust/crates/ccusage-test-support/src/zcode.rs b/rust/crates/ccusage-test-support/src/zcode.rs new file mode 100644 index 0000000000..2e36541e18 --- /dev/null +++ b/rust/crates/ccusage-test-support/src/zcode.rs @@ -0,0 +1,113 @@ +use std::path::Path; + +use sqlite::Connection; + +/// Describes one completed ZCode usage row for a SQLite fixture. +#[derive(Clone, Copy)] +struct FixtureUsage<'a> { + id: &'a str, + session_id: &'a str, + timestamp: &'a str, + model: &'a str, + input_tokens: i64, + output_tokens: i64, + cache_creation_tokens: i64, + cache_read_tokens: i64, + computed_total_tokens: i64, +} + +/// Creates the schema and representative rows used by ZCode report tests. +pub fn create_fixture(path: impl AsRef) { + let db = sqlite::open(path).unwrap(); + db.execute( + "CREATE TABLE model_usage ( + id TEXT PRIMARY KEY, session_id TEXT, started_at INTEGER, model_id TEXT, + provider_id TEXT, status TEXT, input_tokens INTEGER, output_tokens INTEGER, + cache_creation_input_tokens INTEGER, cache_read_input_tokens INTEGER, + computed_total_tokens INTEGER + )", + ) + .unwrap(); + db.execute("CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, version TEXT)") + .unwrap(); + db.execute( + "INSERT INTO session VALUES + ('session-a', '/workspace/project-a', '0.16.3'), + ('session-b', '/workspace/project-b', '0.16.3')", + ) + .unwrap(); + insert_usage( + &db, + FixtureUsage { + id: "usage-52", + session_id: "session-a", + timestamp: "2099-01-02T00:00:00.000Z", + model: "GLM-5.2", + input_tokens: 100, + output_tokens: 10, + cache_creation_tokens: 15, + cache_read_tokens: 25, + computed_total_tokens: 110, + }, + ); + insert_usage( + &db, + FixtureUsage { + id: "usage-53", + session_id: "session-a", + timestamp: "2099-01-15T12:00:00.000Z", + model: "GLM-5.3", + input_tokens: 200, + output_tokens: 20, + cache_creation_tokens: 30, + cache_read_tokens: 40, + computed_total_tokens: 220, + }, + ); + insert_usage( + &db, + FixtureUsage { + id: "usage-53-b", + session_id: "session-b", + timestamp: "2099-02-01T00:00:00.000Z", + model: "GLM-5.3", + input_tokens: 50, + output_tokens: 5, + cache_creation_tokens: 0, + cache_read_tokens: 10, + computed_total_tokens: 55, + }, + ); +} + +/// Inserts one completed usage row into a ZCode fixture database. +fn insert_usage(db: &Connection, usage: FixtureUsage<'_>) { + let mut statement = db + .prepare( + "INSERT INTO model_usage + (id, session_id, started_at, model_id, provider_id, status, input_tokens, + output_tokens, cache_creation_input_tokens, cache_read_input_tokens, + computed_total_tokens) + VALUES (?1, ?2, ?3, ?4, 'builtin:zai-coding-plan', 'completed', ?5, ?6, ?7, ?8, ?9)", + ) + .unwrap(); + statement.bind((1, usage.id)).unwrap(); + statement.bind((2, usage.session_id)).unwrap(); + statement + .bind(( + 3, + usage + .timestamp + .parse::() + .unwrap() + .as_millisecond(), + )) + .unwrap(); + statement.bind((4, usage.model)).unwrap(); + statement.bind((5, usage.input_tokens)).unwrap(); + statement.bind((6, usage.output_tokens)).unwrap(); + statement.bind((7, usage.cache_creation_tokens)).unwrap(); + statement.bind((8, usage.cache_read_tokens)).unwrap(); + statement.bind((9, usage.computed_total_tokens)).unwrap(); + statement.next().unwrap(); +} diff --git a/rust/crates/ccusage/tests/zcode_cli.rs b/rust/crates/ccusage/tests/zcode_cli.rs index bf7f16032a..15f8b4e29d 100644 --- a/rust/crates/ccusage/tests/zcode_cli.rs +++ b/rust/crates/ccusage/tests/zcode_cli.rs @@ -1,13 +1,10 @@ -use std::path::Path; - -use ccusage_core::parse_ts_timestamp; -use ccusage_test_support::Fixture; +use ccusage_test_support::{Fixture, zcode::create_fixture}; #[test] fn zcode_cli_tables_snapshot_production_stdout_and_stderr() { let fixture = Fixture::new(); let _ = fixture.create_dir_all("zcode/cli/db"); - create_zcode_fixture(&fixture.path("zcode/cli/db/db.sqlite")); + create_fixture(fixture.path("zcode/cli/db/db.sqlite")); for kind in ["daily", "monthly", "session"] { let output = std::process::Command::new(env!("CARGO_BIN_EXE_ccusage")) @@ -47,102 +44,3 @@ fn zcode_cli_tables_snapshot_production_stdout_and_stderr() { ); } } - -fn create_zcode_fixture(path: &Path) { - let db = sqlite::open(path).unwrap(); - db.execute( - "CREATE TABLE model_usage ( - id TEXT PRIMARY KEY, session_id TEXT, started_at INTEGER, model_id TEXT, - provider_id TEXT, status TEXT, input_tokens INTEGER, output_tokens INTEGER, - cache_creation_input_tokens INTEGER, cache_read_input_tokens INTEGER, - computed_total_tokens INTEGER - )", - ) - .unwrap(); - db.execute("CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, version TEXT)") - .unwrap(); - db.execute( - "INSERT INTO session VALUES - ('session-a', '/workspace/project-a', '0.16.3'), - ('session-b', '/workspace/project-b', '0.16.3')", - ) - .unwrap(); - insert_zcode_usage( - &db, - ZcodeFixtureUsage { - id: "usage-52", - session_id: "session-a", - timestamp: "2099-01-02T00:00:00.000Z", - model: "GLM-5.2", - input_tokens: 100, - output_tokens: 10, - cache_creation_tokens: 15, - cache_read_tokens: 25, - computed_total_tokens: 110, - }, - ); - insert_zcode_usage( - &db, - ZcodeFixtureUsage { - id: "usage-53", - session_id: "session-a", - timestamp: "2099-01-15T12:00:00.000Z", - model: "GLM-5.3", - input_tokens: 200, - output_tokens: 20, - cache_creation_tokens: 30, - cache_read_tokens: 40, - computed_total_tokens: 220, - }, - ); - insert_zcode_usage( - &db, - ZcodeFixtureUsage { - id: "usage-53-b", - session_id: "session-b", - timestamp: "2099-02-01T00:00:00.000Z", - model: "GLM-5.3", - input_tokens: 50, - output_tokens: 5, - cache_creation_tokens: 0, - cache_read_tokens: 10, - computed_total_tokens: 55, - }, - ); -} - -struct ZcodeFixtureUsage<'a> { - id: &'a str, - session_id: &'a str, - timestamp: &'a str, - model: &'a str, - input_tokens: i64, - output_tokens: i64, - cache_creation_tokens: i64, - cache_read_tokens: i64, - computed_total_tokens: i64, -} - -fn insert_zcode_usage(db: &sqlite::Connection, usage: ZcodeFixtureUsage<'_>) { - let mut statement = db - .prepare( - "INSERT INTO model_usage - (id, session_id, started_at, model_id, provider_id, status, input_tokens, - output_tokens, cache_creation_input_tokens, cache_read_input_tokens, - computed_total_tokens) - VALUES (?1, ?2, ?3, ?4, 'builtin:zai-coding-plan', 'completed', ?5, ?6, ?7, ?8, ?9)", - ) - .unwrap(); - statement.bind((1, usage.id)).unwrap(); - statement.bind((2, usage.session_id)).unwrap(); - statement - .bind((3, parse_ts_timestamp(usage.timestamp).unwrap().as_millis())) - .unwrap(); - statement.bind((4, usage.model)).unwrap(); - statement.bind((5, usage.input_tokens)).unwrap(); - statement.bind((6, usage.output_tokens)).unwrap(); - statement.bind((7, usage.cache_creation_tokens)).unwrap(); - statement.bind((8, usage.cache_read_tokens)).unwrap(); - statement.bind((9, usage.computed_total_tokens)).unwrap(); - statement.next().unwrap(); -} From aa4e00f8c91a06468d38d8ec0ff43549cbe94ad4 Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:10:46 +0100 Subject: [PATCH 09/10] docs(guide): clarify agent path variables State the cardinality of the agent path variables explicitly: Copilot uses one JSONL export file, Grok uses one root, and ZCode can search multiple roots. Keep the comma-separated directory guidance limited to directory variables. --- docs/guide/getting-started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 03ab059d64..d5834d1726 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -203,7 +203,7 @@ export GROK_HOME="/path/to/grok-home" export ZCODE_HOME="/path/to/zcode-home" ``` -Each source-specific path variable can also contain comma-separated directories, except `GROK_HOME`, which takes a single root. `ZCODE_HOME` accepts multiple roots and deduplicates them: +Directory variables can contain comma-separated directories. `COPILOT_OTEL_FILE_EXPORTER_PATH` points to one JSONL file, `GROK_HOME` accepts one root, and `ZCODE_HOME` supports multiple roots and deduplicates them: ```bash export CODEX_HOME="/path/to/codex,/archive/codex,/path/to/codex-exec-jsonl" From 6a38480d7687638d6c53a3783a6c7d05a468945c Mon Sep 17 00:00:00 2001 From: ryoppippi <1560508+ryoppippi@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:10:32 +0100 Subject: [PATCH 10/10] fix(zcode): saturate report token arithmetic Apply saturating arithmetic to shared, focused, and unified report accumulation and totals so extreme counters cannot panic or wrap. Add daily and weekly report regressions and document the ZCode fixture surface and its dependencies. Co-authored-by: axisrow --- rust/adapters/zcode/src/report.rs | 41 +++++++++- rust/crates/ccusage-adapter-all/src/report.rs | 37 +++++++-- rust/crates/ccusage-adapter-all/src/tests.rs | 81 +++++++++++++++++++ rust/crates/ccusage-adapter-all/src/types.rs | 52 +++++++----- rust/crates/ccusage-core/src/output.rs | 47 +++++++---- rust/crates/ccusage-core/src/summary.rs | 53 ++++++++---- rust/crates/ccusage-core/src/types.rs | 57 ++++++++++--- rust/crates/ccusage-test-support/README.md | 5 ++ 8 files changed, 301 insertions(+), 72 deletions(-) diff --git a/rust/adapters/zcode/src/report.rs b/rust/adapters/zcode/src/report.rs index 91906c83c9..772c705170 100644 --- a/rust/adapters/zcode/src/report.rs +++ b/rust/adapters/zcode/src/report.rs @@ -132,7 +132,7 @@ mod tests { } #[test] - fn snapshots_focused_zcode_json_reports_for_daily_monthly_and_session() { + fn snapshots_focused_zcode_json_reports_for_daily_monthly_weekly_and_session() { let entries = snapshot_entries(); let daily = summarize_entries(&entries, AgentReportKind::Daily).unwrap(); let monthly = summarize_entries(&entries, AgentReportKind::Monthly).unwrap(); @@ -157,6 +157,45 @@ mod tests { ); } + #[test] + fn saturates_focused_daily_and_weekly_reports_for_extreme_counters() { + let entries = [ + extreme_entry("2099-01-02", 4_070_908_800_000, u64::MAX), + extreme_entry("2099-01-03", 4_071_081_600_000, 1), + ]; + let daily = summarize_entries(&entries, AgentReportKind::Daily).unwrap(); + let daily_report = report_from_rows(&daily, AgentReportKind::Daily); + let weekly = summarize_entries(&entries, AgentReportKind::Weekly).unwrap(); + let weekly_report = report_from_rows(&weekly, AgentReportKind::Weekly); + + for report in [daily_report, weekly_report] { + for key in [ + "inputTokens", + "outputTokens", + "cacheCreationTokens", + "cacheReadTokens", + "totalTokens", + ] { + assert_eq!(report["totals"][key], u64::MAX, "{key}"); + } + } + assert_eq!(weekly[0].model_breakdowns[0].input_tokens, u64::MAX); + assert_eq!(weekly[0].model_breakdowns[0].output_tokens, u64::MAX); + } + + fn extreme_entry(date: &str, millis: i64, tokens: u64) -> LoadedEntry { + let mut entry = entry("session-extreme", date, millis); + entry.data.message.usage = TokenUsageRaw { + input_tokens: tokens, + output_tokens: tokens, + cache_creation_input_tokens: tokens, + cache_read_input_tokens: tokens, + ..TokenUsageRaw::default() + }; + entry.extra_total_tokens = tokens; + entry + } + fn snapshot_entries() -> Vec { [ ( diff --git a/rust/crates/ccusage-adapter-all/src/report.rs b/rust/crates/ccusage-adapter-all/src/report.rs index b42d7395cb..e8b95f5881 100644 --- a/rust/crates/ccusage-adapter-all/src/report.rs +++ b/rust/crates/ccusage-adapter-all/src/report.rs @@ -234,11 +234,26 @@ fn agent_json(row: &AllRow) -> Value { fn totals_json(rows: &[AllRow], include_sources: bool) -> Value { let mut totals = json!({ - "inputTokens": rows.iter().map(|row| row.input_tokens).sum::(), - "outputTokens": rows.iter().map(|row| row.output_tokens).sum::(), - "cacheCreationTokens": rows.iter().map(|row| row.cache_creation_tokens).sum::(), - "cacheReadTokens": rows.iter().map(|row| row.cache_read_tokens).sum::(), - "totalTokens": rows.iter().map(|row| row.total_tokens).sum::(), + "inputTokens": rows + .iter() + .map(|row| row.input_tokens) + .fold(0, u64::saturating_add), + "outputTokens": rows + .iter() + .map(|row| row.output_tokens) + .fold(0, u64::saturating_add), + "cacheCreationTokens": rows + .iter() + .map(|row| row.cache_creation_tokens) + .fold(0, u64::saturating_add), + "cacheReadTokens": rows + .iter() + .map(|row| row.cache_read_tokens) + .fold(0, u64::saturating_add), + "totalTokens": rows + .iter() + .map(|row| row.total_tokens) + .fold(0, u64::saturating_add), "totalCost": json_float(rows.iter().map(|row| row.total_cost).sum::()), }); if include_sources { @@ -356,7 +371,10 @@ pub(super) fn print_table_with_options( } table.separator(); let totals = totals_json(rows, include_sources); - let table_total_tokens = rows.iter().map(table_total_tokens).sum::(); + let table_total_tokens = rows + .iter() + .map(table_total_tokens) + .fold(0, u64::saturating_add); if compact { let mut total_row = vec![ color(shared, "Total", Color::Yellow), @@ -721,8 +739,11 @@ fn push_model_breakdown_rows( shared: &SharedArgs, ) { for b in breakdowns { - let total = - b.input_tokens + b.output_tokens + b.cache_creation_tokens + b.cache_read_tokens; + let total = b + .input_tokens + .saturating_add(b.output_tokens) + .saturating_add(b.cache_creation_tokens) + .saturating_add(b.cache_read_tokens); let model = color( shared, format!("- {}", short_model_name(&b.model_name)), diff --git a/rust/crates/ccusage-adapter-all/src/tests.rs b/rust/crates/ccusage-adapter-all/src/tests.rs index 05653e9520..64fa783f0b 100644 --- a/rust/crates/ccusage-adapter-all/src/tests.rs +++ b/rust/crates/ccusage-adapter-all/src/tests.rs @@ -137,6 +137,87 @@ fn aggregates_daily_agent_rows_by_period() { assert_eq!(breakdowns[1].agent, "codex"); } +#[test] +fn saturates_unified_daily_and_weekly_reports_for_extreme_counters() { + let rows = vec![ + extreme_all_row("2099-01-02", u64::MAX), + extreme_all_row("2099-01-03", 1), + ]; + let daily_rows = aggregate_rows(rows.clone(), AgentReportKind::Daily); + let daily_report = report_json_with_options(&daily_rows, AgentReportKind::Daily, false, true); + let weekly_rows = aggregate_rows(rows, AgentReportKind::Weekly); + let weekly_report = + report_json_with_options(&weekly_rows, AgentReportKind::Weekly, false, true); + + for report in [daily_report, weekly_report] { + for key in [ + "inputTokens", + "outputTokens", + "cacheCreationTokens", + "cacheReadTokens", + "totalTokens", + ] { + assert_eq!(report["totals"][key], u64::MAX, "{key}"); + } + assert_eq!( + report["totals"]["sourceBreakdowns"][0]["inputTokens"], + u64::MAX + ); + assert_eq!( + report["totals"]["sourceBreakdowns"][0]["modelBreakdowns"][0]["inputTokens"], + u64::MAX + ); + } + assert_eq!(weekly_rows[0].model_breakdowns[0].input_tokens, u64::MAX); + assert_eq!(weekly_rows[0].model_breakdowns[0].output_tokens, u64::MAX); +} + +fn extreme_all_row(period: &str, tokens: u64) -> AllRow { + AllRow { + period: period.to_string(), + agent: "codex", + models_used: vec!["gpt-5".to_string()], + input_tokens: tokens, + output_tokens: tokens, + cache_creation_tokens: tokens, + cache_read_tokens: tokens, + total_tokens: tokens, + total_cost: 0.0, + metadata: Some(json!({ + "sourceBreakdowns": [{ + "source": "Responses API", + "modelsUsed": ["gpt-5"], + "inputTokens": tokens, + "outputTokens": tokens, + "cacheCreationTokens": tokens, + "cacheReadTokens": tokens, + "reasoningOutputTokens": tokens, + "totalTokens": tokens, + "totalCost": 0.0, + "modelBreakdowns": [{ + "modelName": "gpt-5", + "inputTokens": tokens, + "outputTokens": tokens, + "cacheCreationTokens": tokens, + "cacheReadTokens": tokens, + "cost": 0.0 + }] + }] + })), + metadata_agents: Some(vec!["codex"]), + agent_breakdowns: None, + model_breakdowns: vec![ModelBreakdown { + model_name: "gpt-5".to_string(), + input_tokens: tokens, + output_tokens: tokens, + cache_creation_tokens: tokens, + cache_read_tokens: tokens, + extra_total_tokens: tokens, + ..ModelBreakdown::default() + }], + } +} + #[test] fn merges_same_agent_daily_rows_into_one_monthly_breakdown() { let rows = aggregate_rows( diff --git a/rust/crates/ccusage-adapter-all/src/types.rs b/rust/crates/ccusage-adapter-all/src/types.rs index 49966b9050..77dc439fff 100644 --- a/rust/crates/ccusage-adapter-all/src/types.rs +++ b/rust/crates/ccusage-adapter-all/src/types.rs @@ -79,11 +79,13 @@ pub(super) struct AllAccumulator { impl AllAccumulator { pub(super) fn add(&mut self, row: AllRow) { - self.input_tokens += row.input_tokens; - self.output_tokens += row.output_tokens; - self.cache_creation_tokens += row.cache_creation_tokens; - self.cache_read_tokens += row.cache_read_tokens; - self.total_tokens += row.total_tokens; + self.input_tokens = self.input_tokens.saturating_add(row.input_tokens); + self.output_tokens = self.output_tokens.saturating_add(row.output_tokens); + self.cache_creation_tokens = self + .cache_creation_tokens + .saturating_add(row.cache_creation_tokens); + self.cache_read_tokens = self.cache_read_tokens.saturating_add(row.cache_read_tokens); + self.total_tokens = self.total_tokens.saturating_add(row.total_tokens); self.total_cost += row.total_cost; self.models.extend(row.models_used.iter().cloned()); if let Some(agents) = row.metadata_agents.as_ref() { @@ -132,11 +134,15 @@ impl AllAccumulator { } fn merge_agent_breakdown(target: &mut AllRow, source: AllRow) { - target.input_tokens += source.input_tokens; - target.output_tokens += source.output_tokens; - target.cache_creation_tokens += source.cache_creation_tokens; - target.cache_read_tokens += source.cache_read_tokens; - target.total_tokens += source.total_tokens; + target.input_tokens = target.input_tokens.saturating_add(source.input_tokens); + target.output_tokens = target.output_tokens.saturating_add(source.output_tokens); + target.cache_creation_tokens = target + .cache_creation_tokens + .saturating_add(source.cache_creation_tokens); + target.cache_read_tokens = target + .cache_read_tokens + .saturating_add(source.cache_read_tokens); + target.total_tokens = target.total_tokens.saturating_add(source.total_tokens); target.total_cost += source.total_cost; let mut models: BTreeSet = target.models_used.drain(..).collect(); models.extend(source.models_used); @@ -209,7 +215,7 @@ fn add_json_number(target: &mut Map, source: &Map, return; }; let value = match (target_value.as_u64(), source_value.as_u64()) { - (Some(target), Some(source)) => json!(target + source), + (Some(target), Some(source)) => json!(target.saturating_add(source)), _ => { json_float(target_value.as_f64().unwrap_or(0.0) + source_value.as_f64().unwrap_or(0.0)) } @@ -301,11 +307,13 @@ fn merge_model_breakdowns( i }); let b = &mut breakdowns[index]; - b.input_tokens += item.input_tokens; - b.output_tokens += item.output_tokens; - b.cache_creation_tokens += item.cache_creation_tokens; - b.cache_read_tokens += item.cache_read_tokens; - b.extra_total_tokens += item.extra_total_tokens; + b.input_tokens = b.input_tokens.saturating_add(item.input_tokens); + b.output_tokens = b.output_tokens.saturating_add(item.output_tokens); + b.cache_creation_tokens = b + .cache_creation_tokens + .saturating_add(item.cache_creation_tokens); + b.cache_read_tokens = b.cache_read_tokens.saturating_add(item.cache_read_tokens); + b.extra_total_tokens = b.extra_total_tokens.saturating_add(item.extra_total_tokens); b.cost += item.cost; b.missing_pricing |= item.missing_pricing; } @@ -327,11 +335,13 @@ fn aggregate_model_breakdowns(rows: &[AllRow]) -> Vec { i }); let b = &mut breakdowns[index]; - b.input_tokens += item.input_tokens; - b.output_tokens += item.output_tokens; - b.cache_creation_tokens += item.cache_creation_tokens; - b.cache_read_tokens += item.cache_read_tokens; - b.extra_total_tokens += item.extra_total_tokens; + b.input_tokens = b.input_tokens.saturating_add(item.input_tokens); + b.output_tokens = b.output_tokens.saturating_add(item.output_tokens); + b.cache_creation_tokens = b + .cache_creation_tokens + .saturating_add(item.cache_creation_tokens); + b.cache_read_tokens = b.cache_read_tokens.saturating_add(item.cache_read_tokens); + b.extra_total_tokens = b.extra_total_tokens.saturating_add(item.extra_total_tokens); b.cost += item.cost; b.missing_pricing |= item.missing_pricing; } diff --git a/rust/crates/ccusage-core/src/output.rs b/rust/crates/ccusage-core/src/output.rs index de9c219531..4baf6cb470 100644 --- a/rust/crates/ccusage-core/src/output.rs +++ b/rust/crates/ccusage-core/src/output.rs @@ -77,20 +77,36 @@ pub fn session_summary_json(row: &UsageSummary) -> Value { } pub fn totals_json(rows: &[UsageSummary]) -> Value { - let input = rows.iter().map(|row| row.input_tokens).sum::(); - let output = rows.iter().map(|row| row.output_tokens).sum::(); + let input = rows + .iter() + .map(|row| row.input_tokens) + .fold(0, u64::saturating_add); + let output = rows + .iter() + .map(|row| row.output_tokens) + .fold(0, u64::saturating_add); let cache_create = rows .iter() .map(|row| row.cache_creation_tokens) - .sum::(); - let cache_read = rows.iter().map(|row| row.cache_read_tokens).sum::(); - let extra = rows.iter().map(|row| row.extra_total_tokens).sum::(); + .fold(0, u64::saturating_add); + let cache_read = rows + .iter() + .map(|row| row.cache_read_tokens) + .fold(0, u64::saturating_add); + let extra = rows + .iter() + .map(|row| row.extra_total_tokens) + .fold(0, u64::saturating_add); let mut value = json!({ "inputTokens": input, "outputTokens": output, "cacheCreationTokens": cache_create, "cacheReadTokens": cache_read, - "totalTokens": input + output + cache_create + cache_read + extra, + "totalTokens": input + .saturating_add(output) + .saturating_add(cache_create) + .saturating_add(cache_read) + .saturating_add(extra), "totalCost": rows.iter().map(|row| row.total_cost).sum::(), }); let credits = rows.iter().filter_map(|row| row.credits).sum::(); @@ -299,10 +315,12 @@ pub fn print_usage_table_with_options( .get("totalCost") .and_then(Value::as_f64) .unwrap_or_default(); - let total_tokens = totals - .get("totalTokens") - .and_then(Value::as_u64) - .unwrap_or(input + output + cache_create + cache_read); + let total_tokens = totals.get("totalTokens").and_then(Value::as_u64).unwrap_or( + input + .saturating_add(output) + .saturating_add(cache_create) + .saturating_add(cache_read), + ); table.separator(); let mut total_row = vec![ color(shared, "Total", Color::Yellow), @@ -437,10 +455,11 @@ fn push_breakdown_rows( shared: &SharedArgs, ) { for breakdown in &row.model_breakdowns { - let total = breakdown.input_tokens - + breakdown.output_tokens - + breakdown.cache_creation_tokens - + breakdown.cache_read_tokens; + let total = breakdown + .input_tokens + .saturating_add(breakdown.output_tokens) + .saturating_add(breakdown.cache_creation_tokens) + .saturating_add(breakdown.cache_read_tokens); let mut values = vec![ color( shared, diff --git a/rust/crates/ccusage-core/src/summary.rs b/rust/crates/ccusage-core/src/summary.rs index 65ed8d5ac8..695c212500 100644 --- a/rust/crates/ccusage-core/src/summary.rs +++ b/rust/crates/ccusage-core/src/summary.rs @@ -51,7 +51,10 @@ impl UsageAccumulator { fn add_entry(&mut self, entry: &LoadedEntry) { let usage = entry.data.message.usage; self.counts.add_usage(usage); - self.counts.extra_total_tokens += entry.extra_total_tokens; + self.counts.extra_total_tokens = self + .counts + .extra_total_tokens + .saturating_add(entry.extra_total_tokens); self.cost += entry.cost; if let Some(credits) = entry.credits { *self.credits.get_or_insert(0.0) += credits; @@ -75,11 +78,17 @@ impl UsageAccumulator { index }; let breakdown = &mut self.breakdowns[index]; - breakdown.input_tokens += usage.input_tokens; - breakdown.output_tokens += usage.output_tokens; - breakdown.cache_creation_tokens += usage.cache_creation_token_count(); - breakdown.cache_read_tokens += usage.cache_read_input_tokens; - breakdown.extra_total_tokens += entry.extra_total_tokens; + breakdown.input_tokens = breakdown.input_tokens.saturating_add(usage.input_tokens); + breakdown.output_tokens = breakdown.output_tokens.saturating_add(usage.output_tokens); + breakdown.cache_creation_tokens = breakdown + .cache_creation_tokens + .saturating_add(usage.cache_creation_token_count()); + breakdown.cache_read_tokens = breakdown + .cache_read_tokens + .saturating_add(usage.cache_read_input_tokens); + breakdown.extra_total_tokens = breakdown + .extra_total_tokens + .saturating_add(entry.extra_total_tokens); breakdown.cost += entry.cost; if entry.missing_pricing_model.is_some() { breakdown.missing_pricing = true; @@ -222,11 +231,17 @@ fn aggregate_summaries(rows: &[&UsageSummary]) -> UsageSummary { let mut breakdown_indexes = FxHashMap::::default(); for row in rows { - summary.input_tokens += row.input_tokens; - summary.output_tokens += row.output_tokens; - summary.cache_creation_tokens += row.cache_creation_tokens; - summary.cache_read_tokens += row.cache_read_tokens; - summary.extra_total_tokens += row.extra_total_tokens; + summary.input_tokens = summary.input_tokens.saturating_add(row.input_tokens); + summary.output_tokens = summary.output_tokens.saturating_add(row.output_tokens); + summary.cache_creation_tokens = summary + .cache_creation_tokens + .saturating_add(row.cache_creation_tokens); + summary.cache_read_tokens = summary + .cache_read_tokens + .saturating_add(row.cache_read_tokens); + summary.extra_total_tokens = summary + .extra_total_tokens + .saturating_add(row.extra_total_tokens); summary.total_cost += row.total_cost; if let Some(credits) = row.credits { *summary.credits.get_or_insert(0.0) += credits; @@ -252,11 +267,17 @@ fn aggregate_summaries(rows: &[&UsageSummary]) -> UsageSummary { index }; let breakdown = &mut summary.model_breakdowns[index]; - breakdown.input_tokens += item.input_tokens; - breakdown.output_tokens += item.output_tokens; - breakdown.cache_creation_tokens += item.cache_creation_tokens; - breakdown.cache_read_tokens += item.cache_read_tokens; - breakdown.extra_total_tokens += item.extra_total_tokens; + breakdown.input_tokens = breakdown.input_tokens.saturating_add(item.input_tokens); + breakdown.output_tokens = breakdown.output_tokens.saturating_add(item.output_tokens); + breakdown.cache_creation_tokens = breakdown + .cache_creation_tokens + .saturating_add(item.cache_creation_tokens); + breakdown.cache_read_tokens = breakdown + .cache_read_tokens + .saturating_add(item.cache_read_tokens); + breakdown.extra_total_tokens = breakdown + .extra_total_tokens + .saturating_add(item.extra_total_tokens); breakdown.cost += item.cost; breakdown.missing_pricing |= item.missing_pricing; } diff --git a/rust/crates/ccusage-core/src/types.rs b/rust/crates/ccusage-core/src/types.rs index e81aa6584f..e6e9c44076 100644 --- a/rust/crates/ccusage-core/src/types.rs +++ b/rust/crates/ccusage-core/src/types.rs @@ -76,18 +76,22 @@ pub struct TokenCounts { impl TokenCounts { pub fn add_usage(&mut self, usage: TokenUsageRaw) { - self.input_tokens += usage.input_tokens; - self.output_tokens += usage.output_tokens; - self.cache_creation_tokens += usage.cache_creation_token_count(); - self.cache_read_tokens += usage.cache_read_input_tokens; + self.input_tokens = self.input_tokens.saturating_add(usage.input_tokens); + self.output_tokens = self.output_tokens.saturating_add(usage.output_tokens); + self.cache_creation_tokens = self + .cache_creation_tokens + .saturating_add(usage.cache_creation_token_count()); + self.cache_read_tokens = self + .cache_read_tokens + .saturating_add(usage.cache_read_input_tokens); } pub fn total(&self) -> u64 { self.input_tokens - + self.output_tokens - + self.cache_creation_tokens - + self.cache_read_tokens - + self.extra_total_tokens + .saturating_add(self.output_tokens) + .saturating_add(self.cache_creation_tokens) + .saturating_add(self.cache_read_tokens) + .saturating_add(self.extra_total_tokens) } } @@ -167,9 +171,38 @@ pub struct UsageSummary { impl UsageSummary { pub fn total_tokens(&self) -> u64 { self.input_tokens - + self.output_tokens - + self.cache_creation_tokens - + self.cache_read_tokens - + self.extra_total_tokens + .saturating_add(self.output_tokens) + .saturating_add(self.cache_creation_tokens) + .saturating_add(self.cache_read_tokens) + .saturating_add(self.extra_total_tokens) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn saturates_token_count_accumulation_and_total() { + let mut counts = TokenCounts { + input_tokens: u64::MAX, + output_tokens: u64::MAX, + cache_creation_tokens: u64::MAX, + cache_read_tokens: u64::MAX, + extra_total_tokens: u64::MAX, + }; + counts.add_usage(TokenUsageRaw { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 1, + cache_read_input_tokens: 1, + ..TokenUsageRaw::default() + }); + + assert_eq!(counts.input_tokens, u64::MAX); + assert_eq!(counts.output_tokens, u64::MAX); + assert_eq!(counts.cache_creation_tokens, u64::MAX); + assert_eq!(counts.cache_read_tokens, u64::MAX); + assert_eq!(counts.total(), u64::MAX); } } diff --git a/rust/crates/ccusage-test-support/README.md b/rust/crates/ccusage-test-support/README.md index f26c7b1f76..b5e0be6bb3 100644 --- a/rust/crates/ccusage-test-support/README.md +++ b/rust/crates/ccusage-test-support/README.md @@ -8,6 +8,8 @@ Test-only helpers: filesystem fixtures and environment-variable guards. literal file map, so loader tests read real files instead of mocks. - `EnvVarGuard` and `EnvVarsGuard` โ€” set the data-directory variables an adapter reads and restore them when the test ends. +- `zcode::create_fixture` โ€” create the representative ZCode SQLite schema and + usage rows shared by adapter and unified report tests. Every crate that has tests uses this as a dev-dependency; nothing depends on it at runtime. @@ -17,10 +19,13 @@ at runtime. - `EnvVarGuard` - `EnvVarsGuard` - `Fixture` +- `zcode::create_fixture` ## Depends on - `assert_fs` +- `jiff` +- `sqlite` ## Build layer