Skip to content

feat(usage): Claude/Codex usage monitoring via ccusage sidecar#193

Draft
Fldicoahkiin wants to merge 18 commits into
mainfrom
feat/usage-monitoring
Draft

feat(usage): Claude/Codex usage monitoring via ccusage sidecar#193
Fldicoahkiin wants to merge 18 commits into
mainfrom
feat/usage-monitoring

Conversation

@Fldicoahkiin

@Fldicoahkiin Fldicoahkiin commented May 31, 2026

Copy link
Copy Markdown
Collaborator

Closes #197

What

Adds usage monitoring for Claude Code and Codex, exposed both over HTTP (consumed later by the desktop home page, #141) and as aghub-cli usage subcommands. Two complementary reports:

  1. Consumed usage (token counts + USD cost) — reuses ccusage as a build-time-fetched sidecar rather than reimplementing JSONL parsing/dedup/pricing.
  2. Remaining quota / rate limits — queries each vendor's private OAuth usage endpoint, reusing the token each agent already stores locally.

The reporting + normalization logic lives in a new aghub-usage crate, shared by both the API server and the CLI.

How

aghub-usage crate

  • Runs ccusage claude daily --json --offline and codex daily --json --offline in parallel (tokio::join!) and normalizes the two different per-agent JSON shapes into one DTO; also queries the two limits endpoints.
  • Degrades per-agent — if one agent's call fails it lands in warnings instead of failing the whole report.
  • DTOs exported via ts-rs: UsageReportDto / AgentUsageDto / UsageDayDto / UsageModelDto / UsageTotalsDto and UsageLimitsReportDto / AgentLimitsDto / LimitWindowDto. Token counts are u64 → TS number; cost_usd is nullable (unpriced third-party models report tokens with null cost rather than a fabricated number).
  • ccusage is spawned with kill_on_drop(true) behind a 30s timeout so a hung child is reaped on timeout; error strings carry no local filesystem paths.

GET /api/v1/usage/summary?since&until&timezone (consumed)

  • Spawns the bundled ccusage binary, runs the two daily --json calls in parallel, and returns the unified usage DTO.

GET /api/v1/usage/limits (remaining quota)

  • Queries each vendor's private OAuth usage endpoint for how much of the current rate-limit window is left:
    • Claude: GET api.anthropic.com/api/oauth/usage (Bearer + anthropic-beta: oauth-2025-04-20). Windows: 5h, weekly, and optional weekly_opus / weekly_sonnet.
    • Codex: GET chatgpt.com/backend-api/wham/usage (Bearer + ChatGPT-Account-Id). primary5h, secondaryweekly, parsed defensively (the shape is undocumented and field names vary by version).
  • Reuses the OAuth token already on disk — no new login flow:
    • Claude: macOS login keychain (service Claude Code-credentials), falling back to ~/.claude/.credentials.json.
    • Codex: ~/.codex/auth.json.
  • Each window carries utilization_pct (0–100; Codex's percent_left is inverted so all windows share one meaning) and an optional ISO resets_at. Same per-agent degradation as summary.

aghub-cli usage (new)

The same two reports without the desktop app:

aghub-cli usage summary [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--timezone <IANA>]
aghub-cli usage limits

Agent-agnostic (no -a needed; dispatched before agent resolution). Prints the same JSON the API returns. The CLI does not bundle the sidecar — summary resolves ccusage from AGHUB_CCUSAGE_BIN then PATH.

ccusage as a Tauri sidecar

  • crates/desktop/src-tauri/build.rs downloads the prebuilt per-target-triple binary at build time (pure-Rust build-deps: ureq + flate2 + tar; no npm/JS), stages it into binaries/ccusage-<triple> and wires it via externalBin. All six supported triples are mapped (darwin / linux / windows × arm64 / x64). Binaries stay out of git; fails hard offline. (Replaces the earlier scripts/fetch-ccusage.mjs.)
  • Integrity (not hardcoded): the tarball URL and its SHA-512 are read from the npm registry's package metadata (npm Subresource Integrity), never pinned in source — bumping CCUSAGE_VERSION verifies against that version's own published hash. A tampered or truncated download panics the build instead of shipping a bad binary.
  • Re-fetch on version change: Tauri resolves the sidecar by a fixed ccusage-<triple> name, so the filename can't carry a version; the staged version is tracked in a sibling .ccusage-<triple>.version stamp file and re-downloaded whenever CCUSAGE_VERSION changes.
  • Sidecar path resolution (desktop): a packaged build locates ccusage next to the executable (keeping the platform .exe suffix on Windows); dev falls back to AGHUB_CCUSAGE_BIN then PATH.

Verification

  • cargo clippy --workspace -- -D warnings
  • cargo test -p aghub-usage ✅ (15 tests: Claude/Codex normalization, limit-window parsing, DTO bindings export)
  • just fmt (cargo fmt + prettier) ✅
  • Building the desktop crate exercises the build-time fetch + SHA-512 verification, and re-fetches when the version stamp changes.
  • usage/summary: live request against real local data matches the ccusage CLI (Claude/Codex totals). null cost for unpriced third-party models (e.g. mimo-*); tokens still counted.

How to test

CLI (quickest)

# consumed usage — needs ccusage on PATH or AGHUB_CCUSAGE_BIN
cargo run -p aghub-cli -- usage summary --since 2026-01-01 --until 2026-06-03 --timezone Asia/Shanghai

# remaining quota — needs the agent logged in on this machine
cargo run -p aghub-cli -- usage limits

API server

Run the server standalone (listens on 127.0.0.1:8000):

cargo run -p aghub-api --bin aghub-api

usage/limits (remaining quota)

Needs the agent logged in on the same machine (it reads the local OAuth token):

  • Claude: logged in via Claude Code (token in macOS login keychain Claude Code-credentials, or ~/.claude/.credentials.json).
  • Codex: a ChatGPT-subscription login — ~/.codex/auth.json must contain tokens.access_token + tokens.account_id. API-key-only Codex setups have no subscription quota and report a warnings entry.
curl -s http://127.0.0.1:8000/api/v1/usage/limits | jq

Expected shape (verified live against the Claude endpoint):

{
  "agents": [
    { "agent": "claude", "windows": [
      { "kind": "5h",            "utilization_pct": 4.0, "resets_at": "2026-06-02T22:20:00+00:00" },
      { "kind": "weekly",        "utilization_pct": 2.0, "resets_at": "2026-06-09T12:00:00+00:00" },
      { "kind": "weekly_sonnet", "utilization_pct": 0.0, "resets_at": "2026-06-09T12:00:00+00:00" }
    ]}
  ],
  "generated_at": "2026-06-02T18:40:29+00:00",
  "warnings": ["codex limits unavailable: ..."]
}

A not-logged-in / failing agent does not 500 the request — it appears in warnings and the other agent still returns. weekly_opus / weekly_sonnet are optional and omitted when the endpoint doesn't report them.

usage/summary (consumed tokens + cost)

Needs the ccusage binary. In a packaged desktop build it's the bundled sidecar; for the standalone server point at one explicitly:

AGHUB_CCUSAGE_BIN=/path/to/ccusage cargo run -p aghub-api --bin aghub-api
# then:
curl -s "http://127.0.0.1:8000/api/v1/usage/summary?since=20260101&until=20260603&timezone=Asia/Shanghai" | jq

Without it, both agents degrade to a warnings entry (failed to spawn ccusage ...).

Regenerating TS bindings

DTO changes must be mirrored to the frontend:

cargo run -p aghub-api --bin export-dto   # writes crates/desktop/src/generated/dto/*
just fmt                                    # prettier normalizes the generated files

Out of scope (follow-ups)

  • Frontend wiring on the home page (waiting on feat(desktop): aghub side panel revamp #141).
  • usage/limits was developed from researched endpoint shapes; a live round-trip against the real endpoints still needs confirming on a logged-in machine (Codex's response shape in particular is undocumented and handled defensively).
  • tauri build packaging size check on a real bundle (sidecar adds ~2.6–3.8 MB per platform).

Summary by CodeRabbit

New Features

  • Added usage summary endpoint displaying token consumption per agent, model, and day with cost information.
  • Added rate limit monitoring endpoint showing remaining quota and utilization across providers.

@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e55710ab-8188-4c2c-a262-901f3e06ce4b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds usage monitoring endpoints to the API by invoking a bundled ccusage sidecar binary to aggregate token consumption across Claude and Codex models, with Tauri desktop integration and build-time binary download from npm. Two new routes (/api/v1/usage/summary and /api/v1/usage/limits) normalize provider-specific JSON formats and query OAuth endpoints for rate-limit quotas.

Changes

Usage Monitoring Feature

Layer / File(s) Summary
Usage DTOs and TypeScript exports
crates/api/src/dto/usage.rs, crates/api/src/dto/mod.rs, crates/api/src/bin/export-dto.rs
Eight public structs (UsageReportDto, AgentUsageDto, UsageDayDto, UsageModelDto, UsageTotalsDto, UsageLimitsReportDto, AgentLimitsDto, LimitWindowDto) define normalized usage response shapes with per-agent, per-day, per-model token/cost breakdowns and rate-limit windows, then exported to TypeScript bindings.
API state management and route registration
crates/api/src/state.rs, crates/api/src/lib.rs, crates/api/src/routes/mod.rs
UsageState struct holds optional ccusage_bin path; ApiOptions extended with ccusage_bin field; Rocket state includes UsageState; two new usage routes mounted at /api/v1; callers and tests updated to pass the new parameter.
Usage summary endpoint and ccusage integration
crates/api/src/routes/usage.rs (lines 1–343)
usage_summary() route locates ccusage binary, runs concurrent version check and per-agent daily usage fetches, deserializes Claude/Codex JSON via typed structs, normalizes provider-specific token/cost fields (Claude reasoning tokens, Codex cache fields) into unified UsageDayDto/UsageModelDto format, and returns aggregated UsageReportDto with per-agent error recovery.
Usage limits endpoint and OAuth credential handling
crates/api/src/routes/usage.rs (lines 350–593)
usage_limits() route loads Claude and Codex tokens from macOS keychain or local JSON, concurrently queries Anthropic OAuth and ChatGPT WHAM endpoints, defensively parses utilization windows with multiple field variants, and returns UsageLimitsReportDto with per-agent warnings on partial failures.
Desktop/Tauri binary path resolution
crates/desktop/src-tauri/src/commands/server.rs
resolve_ccusage_bin() helper locates bundled ccusage executable (from env var, debug-build fallback, or bundled path relative to Tauri binary location) and passes resolved path into ApiOptions when spawning embedded API.
Build-time binary download and Tauri configuration
crates/desktop/src-tauri/build.rs, crates/desktop/src-tauri/tauri.conf.json, crates/desktop/src-tauri/Cargo.toml
fetch_ccusage_sidecar() in build.rs downloads prebuilt ccusage .tgz from npm for target platform, extracts binary, stages into binaries/, and applies permissions. Tauri config declares bundle.externalBin: ["binaries/ccusage"]. Build dependencies include ureq, flate2, tar.
Minor dependency and configuration updates
crates/api/Cargo.toml, .gitignore
Tokio time feature enabled, chrono dependency added, .gitignore rule added for build-time binaries/ directory.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Suggested reviewers

  • AkaraChen
  • danielchim

Poem

🐰 A rabbit hopped through API fields,
Bundling binaries the ccusage yields,
Token counts and limit checks align,
Claude and Codex in queues, sunshine!
Usage monitored, gracefully it flows,
whiskers twitch — the endpoint knows. 📊

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately summarizes the main change: adding usage monitoring support for Claude/Codex via a bundled ccusage sidecar binary.
Linked Issues check ✅ Passed The PR implements backend infrastructure for usage monitoring with two new API endpoints, providing the foundation for the model usage statistics feature requested in issue #197.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing usage monitoring infrastructure: DTOs, API endpoints, sidecar binary bundling, and related configuration.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/usage-monitoring

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/api/src/routes/usage.rs`:
- Around line 43-55: The error messages in the ccusage spawn/path and stderr
branches leak absolute filesystem paths and stderr into API responses (see usage
of bin.to_string_lossy() and String::from_utf8_lossy(&output.stderr) inside
ApiError::internal), which later surface via
fetch_claude_usage/fetch_codex_usage into UsageReportDto.warnings; instead, log
full details (including bin path, error e, output.status and stderr) to the
server logs using your logger/tracing, and return a generic, path-free ApiError
message (e.g. "failed to spawn ccusage" or "ccusage exited with an error") from
the map_err and non-success branches; update the code around the
ApiError::internal calls in spawn/map_err and the output.status failure branch
to stop including bin.to_string_lossy() and raw stderr in the returned error
while retaining them in server-side logs.
- Around line 39-48: The ccusage child can be orphaned when tokio::time::timeout
drops the output() future; update the Command pipeline that builds the child in
the usage handler by calling .kill_on_drop(true) on the tokio::process::Command
(the same expression starting with
tokio::process::Command::new(bin).args(&args)) before invoking .output(), so
that timed-out children are killed; keep the existing timeout/error handling
using CCUSAGE_TIMEOUT and the ApiError mappings.
- Around line 88-101: The deserialization fails when ccusage emits null costs;
change the numeric cost fields to Option<f64> with #[serde(default)] so nulls
deserialize to None instead of causing an error: update CcClaudeDay.total_cost,
CcClaudeModel.cost, CcClaudeTotals.total_cost and the Codex costUSD mapping (the
field that maps to cost_usd) to Option<f64> with default, and adjust any
downstream mapping logic that currently expects f64 to pass through Option<f64>
into the existing output cost_usd: Option<f64> (i.e., map None -> None, Some(v)
-> Some(v)).

In `@crates/desktop/src-tauri/src/commands/server.rs`:
- Around line 35-38: The Windows sidecar path currently joins "ccusage" without
the executable suffix; update the map closure (the chain starting at
tauri::process::current_binary(&app.env()) -> exe.parent().map(|dir|
dir.join("ccusage"))) to append std::env::consts::EXE_SUFFIX to the filename
when joining (e.g., build the filename as "ccusage" + EXE_SUFFIX) so the
resolved path points to the staged .exe on Windows.

In `@scripts/fetch-ccusage.mjs`:
- Around line 102-113: The download code for pkg/tarballUrl creates tarballBytes
without verifying npm's dist.integrity; fetch the package metadata from the npm
registry for `${pkg}` and version `${CCUSAGE_VERSION}`, read its
`dist.integrity` (expecting the "sha512-..." value), compute a sha512 hash of
the downloaded tarballBytes and base64-encode it, then compare to the integrity
suffix and throw/log an error if they differ before any extraction or use;
integrate this verification in the same scope that defines pkg, tarballUrl, and
tarballBytes so the check runs immediately after downloading and before further
processing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: acd55984-a9d7-476c-b7e4-164aa93d20fd

📥 Commits

Reviewing files that changed from the base of the PR and between ca48d93 and dd0cad8.

⛔ Files ignored due to path filters (7)
  • Cargo.lock is excluded by !**/*.lock
  • crates/desktop/src/generated/dto/AgentUsageDto.ts is excluded by !**/generated/**
  • crates/desktop/src/generated/dto/UsageDayDto.ts is excluded by !**/generated/**
  • crates/desktop/src/generated/dto/UsageModelDto.ts is excluded by !**/generated/**
  • crates/desktop/src/generated/dto/UsageReportDto.ts is excluded by !**/generated/**
  • crates/desktop/src/generated/dto/UsageTotalsDto.ts is excluded by !**/generated/**
  • crates/desktop/src/generated/dto/index.ts is excluded by !**/generated/**
📒 Files selected for processing (12)
  • .gitignore
  • crates/api/Cargo.toml
  • crates/api/src/bin/export-dto.rs
  • crates/api/src/dto/mod.rs
  • crates/api/src/dto/usage.rs
  • crates/api/src/lib.rs
  • crates/api/src/routes/mod.rs
  • crates/api/src/routes/usage.rs
  • crates/api/src/state.rs
  • crates/desktop/src-tauri/src/commands/server.rs
  • crates/desktop/src-tauri/tauri.conf.json
  • scripts/fetch-ccusage.mjs

Comment thread crates/api/src/routes/usage.rs Outdated
Comment on lines +39 to +48
let run = tokio::process::Command::new(bin).args(&args).output();
let output = tokio::time::timeout(CCUSAGE_TIMEOUT, run)
.await
.map_err(|_| ApiError::internal("ccusage timed out after 30s"))?
.map_err(|e| {
ApiError::internal(format!(
"failed to spawn ccusage ({}): {e}",
bin.to_string_lossy()
))
})?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does tokio::process::Command kill the child on drop by default, or is kill_on_drop(true) required?

💡 Result:

By default, tokio::process::Command does not kill the child process when the Child handle is dropped [1][2]. It behaves similarly to the standard library's std::process::Command, where the spawned process will continue to execute even after the Child handle has been dropped [1][2]. To ensure that the child process is killed when the Child handle is dropped, you must explicitly call kill_on_drop(true) on your Command instance before spawning the process [1][2]. In summary: - Default behavior: Child process is NOT killed on drop (it continues running) [1][2]. - To kill on drop: You must explicitly set kill_on_drop(true) [1][2].

Citations:


Set kill_on_drop(true) to prevent orphaned ccusage processes on timeout

tokio::time::timeout drops the output() future on expiry, and tokio::process::Command does not kill the child by default when the child handle is dropped. Add kill_on_drop(true) so a timed-out ccusage subprocess is reclaimed.

♻️ Proposed change
-	let run = tokio::process::Command::new(bin).args(&args).output();
+	let run = tokio::process::Command::new(bin)
+		.args(&args)
+		.kill_on_drop(true)
+		.output();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let run = tokio::process::Command::new(bin).args(&args).output();
let output = tokio::time::timeout(CCUSAGE_TIMEOUT, run)
.await
.map_err(|_| ApiError::internal("ccusage timed out after 30s"))?
.map_err(|e| {
ApiError::internal(format!(
"failed to spawn ccusage ({}): {e}",
bin.to_string_lossy()
))
})?;
let run = tokio::process::Command::new(bin)
.args(&args)
.kill_on_drop(true)
.output();
let output = tokio::time::timeout(CCUSAGE_TIMEOUT, run)
.await
.map_err(|_| ApiError::internal("ccusage timed out after 30s"))?
.map_err(|e| {
ApiError::internal(format!(
"failed to spawn ccusage ({}): {e}",
bin.to_string_lossy()
))
})?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api/src/routes/usage.rs` around lines 39 - 48, The ccusage child can
be orphaned when tokio::time::timeout drops the output() future; update the
Command pipeline that builds the child in the usage handler by calling
.kill_on_drop(true) on the tokio::process::Command (the same expression starting
with tokio::process::Command::new(bin).args(&args)) before invoking .output(),
so that timed-out children are killed; keep the existing timeout/error handling
using CCUSAGE_TIMEOUT and the ApiError mappings.

Comment thread crates/api/src/routes/usage.rs Outdated
Comment on lines +43 to +55
.map_err(|e| {
ApiError::internal(format!(
"failed to spawn ccusage ({}): {e}",
bin.to_string_lossy()
))
})?;
if !output.status.success() {
return Err(ApiError::internal(format!(
"ccusage {:?} exited with {}: {}",
args,
output.status,
String::from_utf8_lossy(&output.stderr)
)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Spawn/exit error text leaks the sidecar's filesystem path (and stderr) into the API response.

bin.to_string_lossy() here is the absolute path to the bundled ccusage binary (e.g. /Users/<user>/.../ccusage). This ApiError string is unwrapped via e.body.error in fetch_claude_usage/fetch_codex_usage (Lines 266, 276) and pushed into warnings (Lines 327, 331), which is serialized in the UsageReportDto response. The same applies to the raw stderr on Line 54, which can contain home-directory JSONL paths. This exposes raw filesystem paths (and the OS username) to API clients.

Keep the detailed path/stderr in server-side logs only, and return a generic, path-free message in the error surfaced to callers.

🔒 Sketch: log details, return generic message
-		.map_err(|e| {
-			ApiError::internal(format!(
-				"failed to spawn ccusage ({}): {e}",
-				bin.to_string_lossy()
-			))
-		})?;
+		.map_err(|e| {
+			log::warn!(
+				"failed to spawn ccusage ({}): {e}",
+				bin.to_string_lossy()
+			);
+			ApiError::internal("failed to spawn ccusage")
+		})?;
 	if !output.status.success() {
-		return Err(ApiError::internal(format!(
-			"ccusage {:?} exited with {}: {}",
-			args,
-			output.status,
-			String::from_utf8_lossy(&output.stderr)
-		)));
+		log::warn!(
+			"ccusage {:?} exited with {}: {}",
+			args,
+			output.status,
+			String::from_utf8_lossy(&output.stderr)
+		);
+		return Err(ApiError::internal("ccusage exited unsuccessfully"));
 	}

As per coding guidelines: "NEVER expose raw filesystem paths in API responses".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.map_err(|e| {
ApiError::internal(format!(
"failed to spawn ccusage ({}): {e}",
bin.to_string_lossy()
))
})?;
if !output.status.success() {
return Err(ApiError::internal(format!(
"ccusage {:?} exited with {}: {}",
args,
output.status,
String::from_utf8_lossy(&output.stderr)
)));
.map_err(|e| {
log::warn!(
"failed to spawn ccusage ({}): {e}",
bin.to_string_lossy()
);
ApiError::internal("failed to spawn ccusage")
})?;
if !output.status.success() {
log::warn!(
"ccusage {:?} exited with {}: {}",
args,
output.status,
String::from_utf8_lossy(&output.stderr)
);
return Err(ApiError::internal("ccusage exited unsuccessfully"));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api/src/routes/usage.rs` around lines 43 - 55, The error messages in
the ccusage spawn/path and stderr branches leak absolute filesystem paths and
stderr into API responses (see usage of bin.to_string_lossy() and
String::from_utf8_lossy(&output.stderr) inside ApiError::internal), which later
surface via fetch_claude_usage/fetch_codex_usage into UsageReportDto.warnings;
instead, log full details (including bin path, error e, output.status and
stderr) to the server logs using your logger/tracing, and return a generic,
path-free ApiError message (e.g. "failed to spawn ccusage" or "ccusage exited
with an error") from the map_err and non-success branches; update the code
around the ApiError::internal calls in spawn/map_err and the output.status
failure branch to stop including bin.to_string_lossy() and raw stderr in the
returned error while retaining them in server-side logs.

Comment thread crates/api/src/routes/usage.rs Outdated
Comment on lines +88 to +101
total_cost: f64,
#[serde(default)]
model_breakdowns: Vec<CcClaudeModel>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CcClaudeModel {
model_name: String,
input_tokens: u64,
output_tokens: u64,
cache_creation_tokens: u64,
cache_read_tokens: u64,
cost: f64,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

ccusage daily --json output: is the per-model and daily cost field null for unpriced models?

💡 Result:

In the output of ccusage daily --json, the cost fields—specifically costUSD within the data entries and totalCost within the summary—are typically calculated based on token usage and known pricing models [1][2][3]. There is no specific behavior documented indicating that these fields become null for unpriced models; rather, ccusage relies on its internal pricing data to estimate these costs [4]. If a model is not recognized or lacks associated pricing data in the tool's configuration, the cost estimation logic may fail or produce a zero/default value depending on the calculation mode (auto, calculate, or display) [4]. Technical discussions and documentation suggest that when data is missing or a tool encounters an error, fields like totals may be returned as null [5], but for standard operation, the cost is a numerical estimate derived from tokens [1][3]. If you are encountering null values for cost fields, it is likely because the specific model version used does not exist in the tool's current pricing database, causing it to return an undefined or null cost calculation for those entries.

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the targeted Rust file around the cited lines
FILE="crates/api/src/routes/usage.rs"
echo "== $FILE =="
wc -l "$FILE"

# Show around the provided line range
sed -n '60,190p' "$FILE" | nl -ba | sed -n '1,140p'

# Search for the relevant cost fields/types in this file
rg -n "total_cost|costUSD|cost\b|model_breakdowns|CcClaudeModel" "$FILE"

# Show the full struct definitions if they are near those hits (bounded)
for pat in "struct .*CcClaudeModel" "costUSD" "totalCost" "total_cost"; do
  echo "---- Searching for: $pat"
done

Repository: AkaraChen/aghub

Length of output: 193


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="crates/api/src/routes/usage.rs"
echo "== $FILE =="
wc -l "$FILE"

echo
echo "== Lines 70-130 =="
sed -n '70,130p' "$FILE" | cat -n

echo
echo "== Lines 130-220 =="
sed -n '130,220p' "$FILE" | cat -n

echo
echo "== Grep cost-related fields =="
rg -n "total_cost|totalCost|costUSD|cache_creation_tokens|cache_read_tokens|model_breakdowns|CcClaudeModel|cost:" "$FILE"

echo
echo "== Show derive/serde attributes near CcClaudeModel =="
rg -n "struct CcClaudeModel|derive\(Deserialize\)|rename_all|costUSD|totalCost" "$FILE" -n

Repository: AkaraChen/aghub

Length of output: 7038


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="crates/api/src/routes/usage.rs"

echo "== Search for serde_json parsing + error handling =="
rg -n "serde_json::from_(slice|str)|from_slice|from_str" "$FILE"

echo
echo "== Search for warning/logging/error mapping =="
rg -n "warning|warn!|tracing::warn|error!|tracing::error|StatusCode|bad request|400|500" "$FILE"

Repository: AkaraChen/aghub

Length of output: 566


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="crates/api/src/routes/usage.rs"
echo "== Lines 240-360 =="
sed -n '240,360p' "$FILE" | cat -n

Repository: AkaraChen/aghub

Length of output: 3596


🌐 Web query:

ccusage daily --json costUSD null unpriced models

💡 Result:

The command ccusage daily --json allows you to export your daily token usage and cost data in a structured JSON format [1][2][3]. Regarding the specific parameters you mentioned: - costUSD: In the JSON output, this field represents the estimated cost in US dollars for the reported period [2][3]. It may appear as null if the cost cannot be calculated—for instance, if neither pre-calculated cost data is available in the logs nor sufficient model information exists to perform a calculation [4]. - unpriced models: When ccusage processes your data, it uses model pricing information (typically from LiteLLM) to estimate costs [4]. If a model used in your logs is not recognized or lacks a defined price in the pricing database, the tool may not be able to assign a specific dollar value to that activity [4]. In such cases, if you are using the default auto mode, the cost for that specific activity may be treated as $0.00, or the cost field may reflect a null or omitted value depending on the specific aggregation logic [4]. To manage these scenarios, you can use the --mode flag to control how costs are calculated [4][5]: - --mode auto (default): Uses pre-calculated costUSD values if available; otherwise, it calculates costs from tokens using known model pricing [4]. If no model information is available, it defaults to $0.00 [4]. - --mode calculate: Ignores any existing costUSD values and forces a re-calculation from token counts using current pricing [4]. - --mode display: Uses only the pre-calculated costUSD values found in your local files and will show $0.00 for any entries that lack them [4]. If you are seeing null or unexpected costs for certain models, ensuring your ccusage version is up to date (npx ccusage@latest) is recommended, as it fetches the most recent model pricing definitions [1][6].

Citations:


🌐 Web query:

site:github.com ryoppippi/ccusage costUSD null

💡 Result:

The term costUSD refers to a specific field found in the local JSONL usage logs generated by Claude Code [1][2]. The CLI tool ryoppippi/ccusage uses this field to track and report estimated costs for AI agent interactions [3][2]. Issues involving costUSD and null values in the context of ccusage typically arise from two main scenarios: 1. Missing Fields in Newer Versions: Newer versions of Claude Code and other coding agents sometimes omit the pre-calculated costUSD field in their logs [1][4]. To handle this, ccusage introduced flexible cost modes (e.g., --mode calculate), which allow the tool to compute costs from raw token counts (input, output, and cache tokens) using model pricing data instead of relying on the pre-calculated field [4][2]. 2. Inconsistent API/JSON Outputs: Users have reported inconsistencies where some components or sub-tools (such as ccusage-codex) historically used costUSD while others standardized on totalCost [5]. Additionally, when no usage data is present, some commands may return a null value for the totals object in JSON output, which has been identified as an inconsistency that the maintainers have addressed in various updates [6]. If you are encountering a null value for costUSD or an unexpected cost report, you can generally resolve or troubleshoot it by using the --mode calculate flag or verifying your version of the tool, as frequent updates are released to align these fields across the ccusage ecosystem [4][7].

Citations:


🌐 Web query:

ryoppippi ccusage issue 832 null totals cost

💡 Result:

Issue #832 in the ryoppippi/ccusage repository, titled "Inconsistent totals handling when no data exists across ccusage apps," identifies an issue where the JSON output of the ccusage CLI tools is inconsistent when no usage data is available [1]. Specifically, when generating reports such as daily, monthly, session, or weekly using the --json flag, different apps return varying structures for the totals field [1]. While some tools return a totals object with zeroed values, others may return a totals: null value or omit the totals field entirely [1]. This inconsistency complicates the development of scripts that rely on a predictable output schema across the ccusage ecosystem [1]. The issue report explicitly notes that: - The ccusage reference app is inconsistent internally: monthly and weekly commands include a totals field with zeros, while daily and session may omit it [1]. - Other related apps (like ccusage-codex, ccusage-opencode, ccusage-amp, and ccusage-pi) have been observed returning totals: null or lacking the expected zero-valued totals object [1]. The suggested resolution is to standardize the JSON output across all ccusage apps so that, even when no data exists, they consistently return a totals object populated with zeros (e.g., totals: {inputTokens: 0, outputTokens: 0, totalCost: 0, ...}) rather than null or missing fields [1]. This would improve reliability for automated usage analysis tools and scripts [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="crates/api/src/routes/usage.rs"
echo "== Search DTO definitions in usage.rs =="
rg -n "struct Usage(DayDto|ModelDto|TotalsDto)|cost_usd|costUSD" "$FILE"

echo
echo "== If found, show the surrounding DTO structs =="
# Show a bounded window around the first occurrence of UsageDayDto/UsageTotalsDto/UsageModelDto
for line in $(rg -n "struct Usage(DayDto|ModelDto|TotalsDto)" "$FILE" | cut -d: -f1 | head -n 5); do
  start=$((line-10))
  end=$((line+60))
  echo "--- window ${start}-${end} (around line $line) ---"
  sed -n "${start},${end}p" "$FILE" | cat -n
done

Repository: AkaraChen/aghub

Length of output: 563


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="crates/api/src/routes/usage.rs"
rg -n "struct Usage(DayDto|TotalsDto|ModelDto)|UsageDayDto|UsageTotalsDto|UsageModelDto|cost_usd:" "$FILE"

Repository: AkaraChen/aghub

Length of output: 598


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n --hidden "struct UsageDayDto|struct UsageTotalsDto|struct UsageModelDto|struct UsageReportDto" .

Repository: AkaraChen/aghub

Length of output: 299


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="crates/api/src/dto/usage.rs"
echo "== $FILE =="
wc -l "$FILE"
echo
sed -n '1,120p' "$FILE" | cat -n

Repository: AkaraChen/aghub

Length of output: 2645


Make ccusage cost fields tolerant of null to avoid dropping whole agent usage

In crates/api/src/routes/usage.rs, the ccusage parsing structs type cost as non-optional f64 (CcClaudeDay.total_cost, CcClaudeModel.cost, CcClaudeTotals.total_cost, and Codex costUSD mapped into cost_usd). If ccusage emits null (possible when cost can’t be calculated / pricing is missing), serde_json::from_slice will fail and that agent’s entire usage will be omitted with a warnings entry. Consider parsing these as Option<f64> (e.g., Option<f64> + #[serde(default)]) and mapping None to the existing output cost_usd: Option<f64>.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api/src/routes/usage.rs` around lines 88 - 101, The deserialization
fails when ccusage emits null costs; change the numeric cost fields to
Option<f64> with #[serde(default)] so nulls deserialize to None instead of
causing an error: update CcClaudeDay.total_cost, CcClaudeModel.cost,
CcClaudeTotals.total_cost and the Codex costUSD mapping (the field that maps to
cost_usd) to Option<f64> with default, and adjust any downstream mapping logic
that currently expects f64 to pass through Option<f64> into the existing output
cost_usd: Option<f64> (i.e., map None -> None, Some(v) -> Some(v)).

Comment on lines +35 to +38
tauri::process::current_binary(&app.env())
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join("ccusage")))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Tauri externalBin sidecar bundled binary name on Windows: does it keep the .exe extension after stripping the target triple?

💡 Result:

Yes, the.exe extension must be kept after the target triple on Windows [1][2][3]. When bundling external binaries (sidecars) for Tauri, the binary must follow a specific naming convention: name-target-triple.extension [1][2]. On Windows, this means your bundled file should include both the target triple and the.exe extension (e.g., my-sidecar-x86_64-pc-windows-msvc.exe) [1][4][2]. Official Tauri documentation and examples consistently demonstrate that the extension is preserved as part of this naming pattern [1][2][3]. When preparing your sidecar binaries, you must ensure the extension is present for Windows targets [1][2]. Failure to include the.exe extension will prevent Tauri from correctly identifying and executing the sidecar on Windows [1][2].

Citations:


Fix Windows sidecar path to include the .exe suffix

On Windows, Tauri’s sidecar external binary keeps the .exe extension, so resolving dir.join("ccusage") (no extension) won’t find the staged binary. Use std::env::consts::EXE_SUFFIX.

🛠 Proposed change
 	tauri::process::current_binary(&app.env())
 		.ok()
-		.and_then(|exe| exe.parent().map(|dir| dir.join("ccusage")))
+		.and_then(|exe| {
+			exe.parent().map(|dir| {
+				dir.join(format!("ccusage{}", std::env::consts::EXE_SUFFIX))
+			})
+		})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/desktop/src-tauri/src/commands/server.rs` around lines 35 - 38, The
Windows sidecar path currently joins "ccusage" without the executable suffix;
update the map closure (the chain starting at
tauri::process::current_binary(&app.env()) -> exe.parent().map(|dir|
dir.join("ccusage"))) to append std::env::consts::EXE_SUFFIX to the filename
when joining (e.g., build the filename as "ccusage" + EXE_SUFFIX) so the
resolved path points to the staged .exe on Windows.

Comment thread scripts/fetch-ccusage.mjs Outdated
Comment on lines +102 to +113
const pkg = `@ccusage/ccusage-${platform}`;
const tarballUrl = `https://registry.npmjs.org/${pkg}/-/ccusage-${platform}-${CCUSAGE_VERSION}.tgz`;
console.log(`[fetch-ccusage] downloading ${pkg}@${CCUSAGE_VERSION}`);

const res = await fetch(tarballUrl);
if (!res.ok) {
throw new Error(
`download failed: ${res.status} ${res.statusText} (${tarballUrl})`,
);
}
const tarballBytes = Buffer.from(await res.arrayBuffer());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify current script does not validate npm dist.integrity and inspect metadata availability.
set -euo pipefail

echo "== Script check =="
rg -n 'dist\\.integrity|createHash|sha512-' scripts/fetch-ccusage.mjs || true

echo
echo "== npm metadata check (linux-x64 example) =="
curl -fsSL 'https://registry.npmjs.org/@ccusage/ccusage-linux-x64/20.0.6' \
  | jq -r '.dist | {tarball, integrity}'

Repository: AkaraChen/aghub

Length of output: 342


Add npm tarball integrity (SRI) verification before using/extracting the downloaded bytes

scripts/fetch-ccusage.mjs downloads the .tgz and converts it to tarballBytes without validating npm dist.integrity (no dist.integrity / createHash / sha512- hashing logic is present). npm metadata provides dist.integrity (e.g. sha512-*) for the package/version, so the script should fetch that and compare it against the downloaded tarball’s computed sha512 before extracting native binaries.

Suggested hardening patch
+import { createHash } from "node:crypto";
...
 	const pkg = `@ccusage/ccusage-${platform}`;
 	const tarballUrl = `https://registry.npmjs.org/${pkg}/-/ccusage-${platform}-${CCUSAGE_VERSION}.tgz`;
 	console.log(`[fetch-ccusage] downloading ${pkg}@${CCUSAGE_VERSION}`);
+
+	const metaUrl = `https://registry.npmjs.org/${pkg}/${CCUSAGE_VERSION}`;
+	const metaRes = await fetch(metaUrl);
+	if (!metaRes.ok) {
+		throw new Error(`metadata fetch failed: ${metaRes.status} ${metaRes.statusText}`);
+	}
+	const meta = await metaRes.json();
+	const expectedIntegrity = meta?.dist?.integrity; // e.g. "sha512-<base64>"
+	if (typeof expectedIntegrity !== "string" || !expectedIntegrity.startsWith("sha512-")) {
+		throw new Error(`missing/invalid dist.integrity for ${pkg}@${CCUSAGE_VERSION}`);
+	}
 
 	const res = await fetch(tarballUrl);
...
 	const tarballBytes = Buffer.from(await res.arrayBuffer());
+	const actualIntegrity =
+		"sha512-" + createHash("sha512").update(tarballBytes).digest("base64");
+	if (actualIntegrity !== expectedIntegrity) {
+		throw new Error(`integrity mismatch for ${pkg}@${CCUSAGE_VERSION}`);
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const pkg = `@ccusage/ccusage-${platform}`;
const tarballUrl = `https://registry.npmjs.org/${pkg}/-/ccusage-${platform}-${CCUSAGE_VERSION}.tgz`;
console.log(`[fetch-ccusage] downloading ${pkg}@${CCUSAGE_VERSION}`);
const res = await fetch(tarballUrl);
if (!res.ok) {
throw new Error(
`download failed: ${res.status} ${res.statusText} (${tarballUrl})`,
);
}
const tarballBytes = Buffer.from(await res.arrayBuffer());
const pkg = `@ccusage/ccusage-${platform}`;
const tarballUrl = `https://registry.npmjs.org/${pkg}/-/ccusage-${platform}-${CCUSAGE_VERSION}.tgz`;
console.log(`[fetch-ccusage] downloading ${pkg}@${CCUSAGE_VERSION}`);
const metaUrl = `https://registry.npmjs.org/${pkg}/${CCUSAGE_VERSION}`;
const metaRes = await fetch(metaUrl);
if (!metaRes.ok) {
throw new Error(`metadata fetch failed: ${metaRes.status} ${metaRes.statusText}`);
}
const meta = await metaRes.json();
const expectedIntegrity = meta?.dist?.integrity; // e.g. "sha512-<base64>"
if (typeof expectedIntegrity !== "string" || !expectedIntegrity.startsWith("sha512-")) {
throw new Error(`missing/invalid dist.integrity for ${pkg}@${CCUSAGE_VERSION}`);
}
const res = await fetch(tarballUrl);
if (!res.ok) {
throw new Error(
`download failed: ${res.status} ${res.statusText} (${tarballUrl})`,
);
}
const tarballBytes = Buffer.from(await res.arrayBuffer());
const actualIntegrity =
"sha512-" + createHash("sha512").update(tarballBytes).digest("base64");
if (actualIntegrity !== expectedIntegrity) {
throw new Error(`integrity mismatch for ${pkg}@${CCUSAGE_VERSION}`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/fetch-ccusage.mjs` around lines 102 - 113, The download code for
pkg/tarballUrl creates tarballBytes without verifying npm's dist.integrity;
fetch the package metadata from the npm registry for `${pkg}` and version
`${CCUSAGE_VERSION}`, read its `dist.integrity` (expecting the "sha512-..."
value), compute a sha512 hash of the downloaded tarballBytes and base64-encode
it, then compare to the integrity suffix and throw/log an error if they differ
before any extraction or use; integrate this verification in the same scope that
defines pkg, tarballUrl, and tarballBytes so the check runs immediately after
downloading and before further processing.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@justfile`:
- Line 18: The `test` recipe references a missing `_fetch-ccusage` dependency
causing `just test` to fail; either add a `_fetch-ccusage` recipe that performs
the intended setup (e.g., fetch or generate CC usage data) and ensure it is
defined before or in the same justfile, or remove `_fetch-ccusage` from the
`test` recipe's dependency list if it's no longer required; update the `test`
recipe (and add a short comment) to reflect the chosen approach so `just` can
resolve all recipes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aea2d68b-8adf-4318-a140-1832dd32d96e

📥 Commits

Reviewing files that changed from the base of the PR and between dd0cad8 and 4796c3e.

📒 Files selected for processing (1)
  • justfile

Comment thread justfile Outdated
@AkaraChen

Copy link
Copy Markdown
Owner

@Fldicoahkiin 可以在这个新 crate 的 build.rs 里加上 fetch binary 的环节,就不用手动做了

@AkaraChen AkaraChen marked this pull request as draft June 2, 2026 09:44
@Fldicoahkiin

Copy link
Copy Markdown
Collaborator Author

@AkaraChen 我一开始也是准备放build.rs的但是感觉编译期跑外部下载有点诡异,,

- aghub-api: GET /api/v1/usage/summary spawns the bundled ccusage binary
  and normalizes claude + codex --json into a unified DTO (token counts +
  USD cost); the two calls run in parallel and degrade per-agent on failure
- export the unified DTOs through ts-rs (UsageReportDto, AgentUsageDto,
  UsageDayDto, UsageModelDto, UsageTotalsDto)
- bundle ccusage as a Tauri sidecar: scripts/fetch-ccusage.mjs fetches the
  per-target-triple prebuilt binary at build time, wired via externalBin and
  beforeBuildCommand; the binaries dir stays out of git
- resolve the sidecar path via current_binary and inject it through
  ApiOptions/UsageState, falling back to AGHUB_CCUSAGE_BIN then PATH
- tauri-build validates the externalBin path at compile time, but the
  ccusage binary is gitignored and only fetched by tauri's
  beforeBuildCommand, which plain cargo build/clippy never trigger
- CI's `just lint`/`test`/`build` therefore failed compiling the desktop
  crate on a clean checkout (resource path binaries/ccusage-... missing)
- add a `_fetch-ccusage` just task; make dev/build/test/lint depend on it
  so any just-driven cargo compile fetches the per-triple binary first
- CI already runs through just, so the workflow needs no change
The previous commit added `_fetch-ccusage` as a dependency of the test
task but never defined the recipe, so `just` aborted with "unknown
dependency". Define the recipe and make both lint and test depend on it
(they compile the desktop crate, which needs the gitignored sidecar
binary present for tauri-build's externalBin check).
- port scripts/fetch-ccusage.mjs into src-tauri/build.rs via ureq + flate2 + tar
- drop the node fetch from beforeBuildCommand and the _fetch-ccusage justfile recipe
- workspace builds now stage the sidecar before tauri-build validates externalBin
- GET /api/v1/usage/limits queries each vendor's private OAuth usage
  endpoint for how much of the current rate-limit window is left,
  complementing usage/summary (which only reports consumed tokens).
- Reuses the OAuth token from each agent's local credential store:
  Claude from the macOS login keychain (service "Claude Code-credentials")
  or ~/.claude/.credentials.json; Codex from ~/.codex/auth.json.
- Unified DTOs (UsageLimitsReportDto / AgentLimitsDto / LimitWindowDto)
  exported via ts-rs. Claude windows: 5h, weekly, weekly_opus,
  weekly_sonnet; Codex primary/secondary parsed defensively.
- Degrades per-agent: a not-logged-in or failing agent becomes a
  warnings entry instead of failing the whole request.
@Fldicoahkiin Fldicoahkiin force-pushed the feat/usage-monitoring branch from 110a7f6 to eb081ec Compare June 2, 2026 18:50
@Fldicoahkiin Fldicoahkiin marked this pull request as ready for review June 2, 2026 18:50
@Fldicoahkiin Fldicoahkiin marked this pull request as draft June 2, 2026 18:52
@Fldicoahkiin

Copy link
Copy Markdown
Collaborator Author

更新了描述并且支持了claude/codex 订阅额度查询(详见How to test)
@AkaraChen

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (2)
crates/api/src/routes/usage.rs (2)

46-58: ⚠️ Potential issue | 🟠 Major

Stop returning the sidecar path and raw stderr to clients.

fetch_claude_usage() and fetch_codex_usage() forward e.body.error into UsageReportDto.warnings, so these ApiError::internal(...) strings expose bin.to_string_lossy() and raw stderr in the HTTP response. Keep the detailed path/stderr in server logs only and return a generic message here instead.

As per coding guidelines, "NEVER expose raw filesystem paths in API responses".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api/src/routes/usage.rs` around lines 46 - 58, The ApiError::internal
messages in fetch_claude_usage()/fetch_codex_usage() currently include sensitive
data (bin.to_string_lossy() path and raw output.stderr); instead, log the
detailed error (including bin, args, output.stderr) to the server log and return
a generic error message in the ApiError::internal returned to the client (e.g.,
"failed to invoke usage reporter" or "usage reporter failed"); update the error
branches around spawn (the closure using bin) and the !output.status branch to
call the logger with the full details and construct ApiError::internal with a
non-sensitive, generic string while keeping the original detailed text out of
the HTTP response.

84-115: ⚠️ Potential issue | 🟠 Major

Make ccusage cost fields nullable at the deserialization boundary.

The PR context explicitly calls out null cost for unpriced models, but these ccusage structs still deserialize cost fields as plain f64. One null makes serde_json::from_slice fail and drops the entire agent into warnings instead of returning partial usage. Parse those fields as Option<f64> with #[serde(default)], then pass them through to the existing DTO cost_usd: Option<f64> fields.

In Rust serde, can a JSON field with value `null` deserialize into `f64`, or does it need `Option<f64>` and/or `#[serde(default)]` to accept `null`? Please cite the serde documentation.

Also applies to: 128-160, 173-215, 220-260

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api/src/routes/usage.rs` around lines 84 - 115, The cost fields in the
ccusage deserialization structs (e.g., CcClaudeModel.cost,
CcClaudeDay.total_cost, CcClaudeTotals.total_cost and any other cost f64s in the
indicated ranges) must accept JSON nulls: change their types from f64 to
Option<f64> and add #[serde(default)] to each such field so serde will accept
missing or null values; then propagate those Option<f64> values into the
existing DTO field cost_usd: Option<f64> when mapping. Locate and update
CcClaudeDay, CcClaudeModel, CcClaudeTotals (and the analogous structs in the
other ranges) accordingly.
🧹 Nitpick comments (1)
crates/desktop/src-tauri/build.rs (1)

47-49: 💤 Low value

Wrap the npm tarball URL to stay under the Rust line-width limit.

As per coding guidelines, **/*.rs: Maintain maximum line width of 80 characters in Rust code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/desktop/src-tauri/build.rs` around lines 47 - 49, The URL string
assigned to `url` exceeds the Rust 80-char line-width; update the `format!` call
in `build.rs` (the `url` variable) to wrap the literal so lines stay <=80
chars—either split the string into adjacent quoted pieces or move interpolated
parts into `format!` arguments (e.g. use "https://registry.npmjs.org/" "{}",
"/-/ccusage-" "{}" "-" "{}.tgz" with `pkg`, `platform`, `CCUSAGE_VERSION`) while
keeping the same `format!`/variable names.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/api/src/routes/usage.rs`:
- Around line 387-390: The map_err closures that build errors from reading
credential files (the block that constructs path =
home_dir()?.join(".claude/.credentials.json") and the similar block for
".codex") currently embed path.display() into the returned error string and
those errors get serialized into usage_limits() warnings; change them to return
a generic, path-free error message (e.g., "failed to read credentials" or
"failed to read codex credentials") while logging the full path and underlying
error to server logs instead. Locate the read_to_string(...).map_err(...)
closures and replace the map_err payload with a non-sensitive message, and add a
server-side log call (using the existing logger) that includes path.display()
and the original error for diagnostics; do the same for the other block around
lines 405-410 that reads the ".codex" file.

In `@crates/desktop/src-tauri/build.rs`:
- Around line 46-57: The download step that fetches the ccusage tarball
(variables pkg, url, resp, tarball and constant CCUSAGE_VERSION) lacks integrity
verification; before reading and extracting resp.into_reader() or bundling
tarball, compute and verify a cryptographic digest or signature (e.g., SHA256 of
tarball or a detached signature) against a trusted value supplied via
environment/config (or fetched securely from a signed metadata endpoint), and
fail the build with a clear error if verification fails; ensure this
verification is performed immediately after reading the response into tarball
and before any further processing, and surface detailed errors on mismatch or
missing expected digest/signature.
- Around line 39-42: The build script currently returns early if dest.exists(),
but dest is version-independent so old ccusage binaries can be reused across
CCUSAGE_VERSION bumps; update the logic in build.rs to invalidate and redownload
when CCUSAGE_VERSION changes by either (A) including CCUSAGE_VERSION in the dest
filename/path (so dest depends on CCUSAGE_VERSION) or (B) add a small version
marker (e.g. write/read a .ccusage_version file in the binaries directory) and
if the marker != CCUSAGE_VERSION delete the existing binary and proceed to
download; adjust the early-return that checks dest.exists() (and the variable
named dest) to perform the version check before returning.

---

Duplicate comments:
In `@crates/api/src/routes/usage.rs`:
- Around line 46-58: The ApiError::internal messages in
fetch_claude_usage()/fetch_codex_usage() currently include sensitive data
(bin.to_string_lossy() path and raw output.stderr); instead, log the detailed
error (including bin, args, output.stderr) to the server log and return a
generic error message in the ApiError::internal returned to the client (e.g.,
"failed to invoke usage reporter" or "usage reporter failed"); update the error
branches around spawn (the closure using bin) and the !output.status branch to
call the logger with the full details and construct ApiError::internal with a
non-sensitive, generic string while keeping the original detailed text out of
the HTTP response.
- Around line 84-115: The cost fields in the ccusage deserialization structs
(e.g., CcClaudeModel.cost, CcClaudeDay.total_cost, CcClaudeTotals.total_cost and
any other cost f64s in the indicated ranges) must accept JSON nulls: change
their types from f64 to Option<f64> and add #[serde(default)] to each such field
so serde will accept missing or null values; then propagate those Option<f64>
values into the existing DTO field cost_usd: Option<f64> when mapping. Locate
and update CcClaudeDay, CcClaudeModel, CcClaudeTotals (and the analogous structs
in the other ranges) accordingly.

---

Nitpick comments:
In `@crates/desktop/src-tauri/build.rs`:
- Around line 47-49: The URL string assigned to `url` exceeds the Rust 80-char
line-width; update the `format!` call in `build.rs` (the `url` variable) to wrap
the literal so lines stay <=80 chars—either split the string into adjacent
quoted pieces or move interpolated parts into `format!` arguments (e.g. use
"https://registry.npmjs.org/" "{}", "/-/ccusage-" "{}" "-" "{}.tgz" with `pkg`,
`platform`, `CCUSAGE_VERSION`) while keeping the same `format!`/variable names.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f3674d99-6548-442a-a0f2-954d820c49b7

📥 Commits

Reviewing files that changed from the base of the PR and between c86cf20 and eb081ec.

⛔ Files ignored due to path filters (10)
  • Cargo.lock is excluded by !**/*.lock
  • crates/desktop/src/generated/dto/AgentLimitsDto.ts is excluded by !**/generated/**
  • crates/desktop/src/generated/dto/AgentUsageDto.ts is excluded by !**/generated/**
  • crates/desktop/src/generated/dto/LimitWindowDto.ts is excluded by !**/generated/**
  • crates/desktop/src/generated/dto/UsageDayDto.ts is excluded by !**/generated/**
  • crates/desktop/src/generated/dto/UsageLimitsReportDto.ts is excluded by !**/generated/**
  • crates/desktop/src/generated/dto/UsageModelDto.ts is excluded by !**/generated/**
  • crates/desktop/src/generated/dto/UsageReportDto.ts is excluded by !**/generated/**
  • crates/desktop/src/generated/dto/UsageTotalsDto.ts is excluded by !**/generated/**
  • crates/desktop/src/generated/dto/index.ts is excluded by !**/generated/**
📒 Files selected for processing (13)
  • .gitignore
  • crates/api/Cargo.toml
  • crates/api/src/bin/export-dto.rs
  • crates/api/src/dto/mod.rs
  • crates/api/src/dto/usage.rs
  • crates/api/src/lib.rs
  • crates/api/src/routes/mod.rs
  • crates/api/src/routes/usage.rs
  • crates/api/src/state.rs
  • crates/desktop/src-tauri/Cargo.toml
  • crates/desktop/src-tauri/build.rs
  • crates/desktop/src-tauri/src/commands/server.rs
  • crates/desktop/src-tauri/tauri.conf.json
✅ Files skipped from review due to trivial changes (4)
  • crates/desktop/src-tauri/Cargo.toml
  • crates/api/src/routes/mod.rs
  • .gitignore
  • crates/api/src/dto/mod.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/api/Cargo.toml
  • crates/api/src/state.rs
  • crates/desktop/src-tauri/src/commands/server.rs
  • crates/api/src/lib.rs

Comment thread crates/api/src/routes/usage.rs Outdated
Comment on lines +387 to +390
let path = home_dir()?.join(".claude/.credentials.json");
let json = std::fs::read_to_string(&path)
.map_err(|e| format!("read {}: {e}", path.display()))?;
parse(&json)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Credential read failures still leak absolute home paths in /usage/limits.

Both helpers format path.display() into their error strings, and usage_limits() serializes those strings into warnings on Lines 581 and 585. That exposes user-specific ~/.claude / ~/.codex filesystem paths to API callers. Return a path-free message here and keep the full path only in server-side logs.

As per coding guidelines, "NEVER expose raw filesystem paths in API responses".

Also applies to: 405-410

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api/src/routes/usage.rs` around lines 387 - 390, The map_err closures
that build errors from reading credential files (the block that constructs path
= home_dir()?.join(".claude/.credentials.json") and the similar block for
".codex") currently embed path.display() into the returned error string and
those errors get serialized into usage_limits() warnings; change them to return
a generic, path-free error message (e.g., "failed to read credentials" or
"failed to read codex credentials") while logging the full path and underlying
error to server logs instead. Locate the read_to_string(...).map_err(...)
closures and replace the map_err payload with a non-sensitive message, and add a
server-side log call (using the existing logger) that includes path.display()
and the original error for diagnostics; do the same for the other block around
lines 405-410 that reads the ".codex" file.

Comment thread crates/desktop/src-tauri/build.rs Outdated
Comment on lines +39 to +42
// Filename is version-independent, so bumping CCUSAGE_VERSION needs binaries/ cleared.
if dest.exists() {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Invalidate the cached sidecar when CCUSAGE_VERSION changes.

dest is version-independent, so once binaries/ccusage-<triple> exists this build script stops downloading forever. A later version bump will still package the old executable until someone manually deletes binaries/, which makes upgrades easy to ship incorrectly.

Proposed fix
+	let version_marker =
+		binaries_dir.join(format!("ccusage-{triple}.version"));
+
 	// Filename is version-independent, so bumping CCUSAGE_VERSION needs binaries/ cleared.
-	if dest.exists() {
+	if dest.exists()
+		&& fs::read_to_string(&version_marker).ok().as_deref()
+			== Some(CCUSAGE_VERSION)
+	{
 		return;
 	}
@@
 		fs::rename(&tmp, &dest).expect("rename sidecar into place");
+		fs::write(&version_marker, CCUSAGE_VERSION)
+			.expect("write sidecar version marker");
 		staged = true;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Filename is version-independent, so bumping CCUSAGE_VERSION needs binaries/ cleared.
if dest.exists() {
return;
}
let version_marker =
binaries_dir.join(format!("ccusage-{triple}.version"));
// Filename is version-independent, so bumping CCUSAGE_VERSION needs binaries/ cleared.
if dest.exists()
&& fs::read_to_string(&version_marker).ok().as_deref()
== Some(CCUSAGE_VERSION)
{
return;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/desktop/src-tauri/build.rs` around lines 39 - 42, The build script
currently returns early if dest.exists(), but dest is version-independent so old
ccusage binaries can be reused across CCUSAGE_VERSION bumps; update the logic in
build.rs to invalidate and redownload when CCUSAGE_VERSION changes by either (A)
including CCUSAGE_VERSION in the dest filename/path (so dest depends on
CCUSAGE_VERSION) or (B) add a small version marker (e.g. write/read a
.ccusage_version file in the binaries directory) and if the marker !=
CCUSAGE_VERSION delete the existing binary and proceed to download; adjust the
early-return that checks dest.exists() (and the variable named dest) to perform
the version check before returning.

Comment on lines +46 to +57
let pkg = format!("@ccusage/ccusage-{platform}");
let url = format!(
"https://registry.npmjs.org/{pkg}/-/ccusage-{platform}-{CCUSAGE_VERSION}.tgz"
);

let resp = ureq::get(&url)
.call()
.unwrap_or_else(|e| panic!("download {url} failed: {e}"));
let mut tarball = Vec::new();
resp.into_reader()
.read_to_end(&mut tarball)
.expect("read ccusage tarball");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Verify the tarball before extracting and bundling it.

This build step downloads a prebuilt executable and ships it inside the desktop app, but it never checks an expected digest or signature. If the registry artifact is tampered with, the compromised binary is what gets bundled.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/desktop/src-tauri/build.rs` around lines 46 - 57, The download step
that fetches the ccusage tarball (variables pkg, url, resp, tarball and constant
CCUSAGE_VERSION) lacks integrity verification; before reading and extracting
resp.into_reader() or bundling tarball, compute and verify a cryptographic
digest or signature (e.g., SHA256 of tarball or a detached signature) against a
trusted value supplied via environment/config (or fetched securely from a signed
metadata endpoint), and fail the build with a clear error if verification fails;
ensure this verification is performed immediately after reading the response
into tarball and before any further processing, and surface detailed errors on
mismatch or missing expected digest/signature.

Move the ccusage shell-out, normalization, and vendor limit-endpoint
logic out of crates/api into a standalone aghub-usage crate so it can be
unit-tested without Rocket or the api binary. The api routes become thin
handlers over aghub_usage::summary / aghub_usage::limits, and the usage
DTOs now live in the new crate (re-exported for ts-rs generation).

- run_ccusage returns String instead of ApiError (no api coupling)
- resolve_ccusage_bin centralizes sidecar/env/PATH lookup for callers
- add unit tests for codex_window parsing and claude/codex normalization
Expose the aghub-usage crate through aghub-cli:

  aghub-cli usage summary [--since --until --timezone]
  aghub-cli usage limits

Both print the same JSON the desktop app consumes. Usage is
agent-agnostic, so it is dispatched before agent/scope resolution and
config loading. The CLI does not bundle the ccusage sidecar, so summary
resolves it from AGHUB_CCUSAGE_BIN or PATH.
@AkaraChen

Copy link
Copy Markdown
Owner

@codex 帮我 resolve 掉 outdate 的 comment,顺便解决一下跟主分支的冲突

@chatgpt-codex-connector

Copy link
Copy Markdown

Summary

  • Committed d1c129a (fix(usage): address sidecar review feedback) and created a follow-up PR.
  • Updated ccusage invocation to use kill_on_drop(true) and return generic API-facing errors while keeping detailed sidecar path/stderr diagnostics in server logs.
  • Made Claude/Codex ccusage cost fields nullable at the deserialization boundary and propagated Option<f64> into the DTO mapping so null costs do not drop an entire agent report.
  • Removed credential file paths from /usage/limits warning payloads while logging full paths server-side for diagnostics.
  • Hardened desktop sidecar downloading by reading npm dist.integrity, verifying the downloaded tarball’s SHA-512 SRI before extraction, and adding build dependencies for that verification.
  • Added a sidecar version marker so CCUSAGE_VERSION bumps invalidate stale cached binaries before packaging.
  • Fixed packaged Windows sidecar lookup to include std::env::consts::EXE_SUFFIX.

Main-branch conflict note

  • I attempted to fetch the latest main branch to resolve conflicts, but this environment blocks GitHub git/curl access with CONNECT tunnel failed, response 403, so I could not perform the merge/rebase locally. The committed patch resolves the still-valid review feedback on the checked-out PR branch.

Testing

  • cargo fmt --all
  • git diff --check
  • ⚠️ git fetch origin master main --prune (blocked by environment proxy: CONNECT tunnel failed, response 403)
  • ⚠️ git fetch https://github.com/AkaraChen/aghub.git main:refs/remotes/origin/main (blocked by environment proxy: CONNECT tunnel failed, response 403)
  • ⚠️ RUSTC_WRAPPER= cargo test -p aghub-usage (blocked while downloading crates: CONNECT tunnel failed, response 403)
  • ⚠️ RUSTC_WRAPPER= cargo test -p aghub-usage --offline (blocked because required crates are not cached locally)

References consulted

View task →

@chatgpt-codex-connector

Copy link
Copy Markdown

Summary

  • 已提交 aaaf751 fix(usage): harden ccusage sidecar handling,并创建了后续 PR:fix(usage): harden usage sidecar follow-ups
  • Hardened ccusage subprocess handling: added .kill_on_drop(true), kept full spawn/exit diagnostics in logs, and returned generic path-free errors to API warnings.
  • Made Claude/Codex ccusage cost fields nullable at the deserialization boundary and added a regression test covering null cost payloads.
  • Sanitized Claude/Codex credential read failures so local credential paths are logged server-side but not returned in /usage/limits warnings.
  • Hardened Tauri sidecar staging with version-marker invalidation, npm dist.integrity SHA-512 verification before extraction, and stale binary replacement.
  • Fixed packaged Windows sidecar resolution to include std::env::consts::EXE_SUFFIX, so it resolves ccusage.exe on Windows.
  • Updated the ignored sidecar-binaries comment to match the current build-script fetch path.

Testing

  • cargo fmt --all --check
  • git diff --check
  • ⚠️ just fmt — environment limitation: just is not installed (/bin/bash: line 1: just: command not found).
  • ⚠️ RUSTC_WRAPPER= cargo test -p aghub-usage --locked — environment limitation: crates.io/GitHub network access is blocked (CONNECT tunnel failed, response 403 while fetching ureq).
  • ⚠️ git fetch origin main --prune — environment limitation: GitHub access is blocked (CONNECT tunnel failed, response 403), and there was no local main ref available; I could not validate/resolve main-branch merge conflicts in this container.

References consulted

View task →

ccusage emits a null cost when it cannot price a model (e.g. a freshly
released model with no pricing data yet). The deserialization structs
typed cost as f64, so a single null failed the whole parse and dropped
that agent's entire report.

- make the five ccusage cost fields Option<f64> with serde(default)
- drop the Some() wrappers in claude_to_agent / codex_to_agent
- add a null-cost deserialization regression test

The DTO already exposed cost_usd as Option<f64>, so no TS regeneration.
- run_usage_report and run_limits_report use tokio::join!, which is gated
  behind tokio's "macros" feature
- workspace builds passed via feature unification (another member enabled
  it), but cargo check -p aghub-usage failed standalone with
  "could not find join in tokio"
…usage

- drop the binary path and credential/auth file paths from error strings;
  they leak local layout into API responses without aiding diagnosis
- set kill_on_drop(true) so a ccusage child is reaped when the 30s timeout
  fires and the output future is dropped, instead of lingering
- Tauri strips the -<triple> part of the sidecar name but keeps the
  executable extension, so on Windows the bundled file is ccusage.exe
- joining a bare "ccusage" missed it; use std::env::consts::EXE_SUFFIX so
  the lookup works on every platform the sidecar is fetched for
- Tauri resolves the sidecar by its fixed ccusage-<triple> name, so the
  filename can't carry a version; the old early-return kept a stale binary
  after a CCUSAGE_VERSION bump
- record the staged version in a sibling .ccusage-<triple>.version stamp and
  re-fetch whenever it no longer matches
- fix the .gitignore comment that still referenced the removed
  scripts/fetch-ccusage.mjs
- read the version's dist.tarball URL and dist.integrity hash from the
  package metadata (abbreviated install-v1 format) instead of constructing
  the URL and trusting whatever bytes come back
- compute the tarball's SHA-512 and compare it to the registry's
  sha512-<base64> integrity string; a mismatch fails the build
- the integrity ships with each version's metadata, so bumping
  CCUSAGE_VERSION needs no hardcoded hash to maintain
- add sha2, base64, serde_json build-dependencies
# Conflicts:
#	crates/api/src/lib.rs
#	crates/desktop/src-tauri/src/commands/server.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: 模型用量统计(可开关)

2 participants