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..0fcc91fc79 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, 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..7d31676a81 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 @@ -212,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/docs/guide/getting-started.md b/docs/guide/getting-started.md index c9799ccb34..d5834d1726 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: +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" @@ -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..71c37fc8cf --- /dev/null +++ b/docs/guide/zcode/index.md @@ -0,0 +1,118 @@ +# 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. 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 + +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..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, 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, 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, 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..bc38a78dbb 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -137,14 +137,17 @@ dependencies = [ "ccusage-adapter-opencode", "ccusage-adapter-pi", "ccusage-adapter-qwen", + "ccusage-adapter-zcode", "ccusage-cli", "ccusage-cli-parser", "ccusage-config", "ccusage-core", "ccusage-test-support", + "insta", "mimalloc", "serde", "serde_json", + "sqlite", "ureq", ] @@ -169,12 +172,14 @@ dependencies = [ "ccusage-adapter-opencode", "ccusage-adapter-pi", "ccusage-adapter-qwen", + "ccusage-adapter-zcode", "ccusage-cli", "ccusage-core", "ccusage-test-support", "insta", "serde", "serde_json", + "sqlite", ] [[package]] @@ -388,6 +393,19 @@ dependencies = [ "serde_json", ] +[[package]] +name = "ccusage-adapter-zcode" +version = "0.0.0" +dependencies = [ + "ccusage-adapter-common", + "ccusage-core", + "ccusage-test-support", + "insta", + "jiff", + "serde_json", + "sqlite", +] + [[package]] name = "ccusage-cli" version = "0.0.0" @@ -449,6 +467,8 @@ name = "ccusage-test-support" version = "0.0.0" dependencies = [ "assert_fs", + "jiff", + "sqlite", ] [[package]] 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..3f3f421954 --- /dev/null +++ b/rust/adapters/zcode/Cargo.toml @@ -0,0 +1,16 @@ +[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 +insta.workspace = true diff --git a/rust/adapters/zcode/README.md b/rust/adapters/zcode/README.md new file mode 100644 index 0000000000..e84362b1ca --- /dev/null +++ b/rust/adapters/zcode/README.md @@ -0,0 +1,92 @@ +# 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. 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. + +## 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..86dd6931dc --- /dev/null +++ b/rust/adapters/zcode/src/loader.rs @@ -0,0 +1,545 @@ +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 id = entry + .data + .message + .id + .as_deref() + .expect("ZCode entries always have message IDs"); + 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 { + "m.provider_id" + } else { + "NULL" + }; + let version = if schema.has_session_version { + "s.version" + } 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(); + 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 + (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 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_with_mode(root: &Path, mode: CostMode) -> Vec { + let _guard = EnvVarGuard::set(super::super::paths::ZCODE_HOME_ENV, root); + let shared = SharedArgs { + 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!({}); + 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 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!({}); + 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 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!({}); + 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..078ae25abe --- /dev/null +++ b/rust/adapters/zcode/src/parser.rs @@ -0,0 +1,398 @@ +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()); + // 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 + } 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" + | "builtin:bigmodel-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 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(); + 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(); + 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..772c705170 --- /dev/null +++ b/rust/adapters/zcode/src/report.rs @@ -0,0 +1,293 @@ +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, format_rfc3339_millis, + parse_ts_timestamp, + }; + 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")); + } + + #[test] + 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(); + let weekly = summarize_entries(&entries, AgentReportKind::Weekly).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_weekly_json", + report_from_rows(&weekly, AgentReportKind::Weekly) + ); + insta::assert_json_snapshot!( + "focused_zcode_session_json", + report_from_rows(&session, AgentReportKind::Session) + ); + } + + #[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 { + [ + ( + "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() + } +} 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_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_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_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" + } + ] +} diff --git a/rust/crates/ccusage-adapter-all/Cargo.toml b/rust/crates/ccusage-adapter-all/Cargo.toml index 88aae66ff3..1ff18f494c 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 @@ -30,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/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..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)), @@ -856,6 +877,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/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 2016f91681..64fa783f0b 100644 --- a/rust/crates/ccusage-adapter-all/src/tests.rs +++ b/rust/crates/ccusage-adapter-all/src/tests.rs @@ -10,16 +10,16 @@ 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; -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 { @@ -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( @@ -654,6 +735,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_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 +900,58 @@ fn fixture_shared(since: &str, until: &str) -> SharedArgs { } } +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, @@ -730,6 +976,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-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-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/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-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-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 ce23b6968c..e6e9c44076 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 } @@ -75,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) } } @@ -166,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-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); + } } 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/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 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/Cargo.toml b/rust/crates/ccusage/Cargo.toml index 2cbb836395..2e14f229b5 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 @@ -46,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/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` 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] 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..15f8b4e29d --- /dev/null +++ b/rust/crates/ccusage/tests/zcode_cli.rs @@ -0,0 +1,46 @@ +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_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}") + ); + } +}