From dcda254d92577b2d18281839f22374d685d411f9 Mon Sep 17 00:00:00 2001 From: Flacier Date: Mon, 1 Jun 2026 04:04:21 +0800 Subject: [PATCH 01/13] feat(usage): add Claude/Codex usage monitoring via ccusage sidecar - 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 --- .gitignore | 3 + Cargo.lock | 1 + crates/api/Cargo.toml | 3 +- crates/api/src/bin/export-dto.rs | 9 + crates/api/src/dto/mod.rs | 1 + crates/api/src/dto/usage.rs | 72 ++++ crates/api/src/lib.rs | 9 +- crates/api/src/routes/mod.rs | 1 + crates/api/src/routes/usage.rs | 340 ++++++++++++++++++ crates/api/src/state.rs | 7 + .../desktop/src-tauri/src/commands/server.rs | 34 +- crates/desktop/src-tauri/tauri.conf.json | 3 +- .../src/generated/dto/AgentUsageDto.ts | 12 + .../desktop/src/generated/dto/UsageDayDto.ts | 29 ++ .../src/generated/dto/UsageModelDto.ts | 15 + .../src/generated/dto/UsageReportDto.ts | 20 ++ .../src/generated/dto/UsageTotalsDto.ts | 11 + crates/desktop/src/generated/dto/index.ts | 5 + scripts/fetch-ccusage.mjs | 126 +++++++ 19 files changed, 697 insertions(+), 4 deletions(-) create mode 100644 crates/api/src/dto/usage.rs create mode 100644 crates/api/src/routes/usage.rs create mode 100644 crates/desktop/src/generated/dto/AgentUsageDto.ts create mode 100644 crates/desktop/src/generated/dto/UsageDayDto.ts create mode 100644 crates/desktop/src/generated/dto/UsageModelDto.ts create mode 100644 crates/desktop/src/generated/dto/UsageReportDto.ts create mode 100644 crates/desktop/src/generated/dto/UsageTotalsDto.ts create mode 100644 scripts/fetch-ccusage.mjs diff --git a/.gitignore b/.gitignore index caa37bed..acb9eb8e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ build/ # ts-rs generated TypeScript bindings (build artifact) crates/api/bindings/ + +# ccusage sidecar binaries — fetched at build time by scripts/fetch-ccusage.mjs +crates/desktop/src-tauri/binaries/ diff --git a/Cargo.lock b/Cargo.lock index fc017fc3..935c466f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,6 +71,7 @@ dependencies = [ "aghub-core", "aghub-git", "aghub-inference", + "chrono", "dirs", "keyring", "log", diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index a58fdf5f..7c5bf880 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -24,7 +24,7 @@ skill = { path = "../skill" } skills-sh = { path = "../skills-sh" } rocket = { version = "0.5", features = [ "json" ] } rocket_cors = "0.6.0" -tokio = { version = "1", features = [ "rt-multi-thread", "macros", "process" ] } +tokio = { version = "1", features = [ "rt-multi-thread", "macros", "process", "time" ] } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } @@ -41,3 +41,4 @@ keyring = { version = "3", features = [ uuid = { version = "1", features = [ "v4" ] } log = "0.4.30" reqwest = { version = "0.13", features = [ "json" ] } +chrono = "0.4" diff --git a/crates/api/src/bin/export-dto.rs b/crates/api/src/bin/export-dto.rs index 52a16ff4..944a8db8 100644 --- a/crates/api/src/bin/export-dto.rs +++ b/crates/api/src/bin/export-dto.rs @@ -4,6 +4,9 @@ use std::{ path::{Path, PathBuf}, }; +use aghub_api::dto::usage::{ + AgentUsageDto, UsageDayDto, UsageModelDto, UsageReportDto, UsageTotalsDto, +}; use aghub_api::dto::{ agents::{ AgentAvailabilityDto, AgentInfo, CapabilitiesDto, McpCapabilitiesDto, @@ -247,6 +250,12 @@ fn main() -> Result<(), Box> { export_type::(&cfg)?; export_type::(&cfg)?; + export_type::(&cfg)?; + export_type::(&cfg)?; + export_type::(&cfg)?; + export_type::(&cfg)?; + export_type::(&cfg)?; + write_index_file(&out_dir)?; if disallowed_dir.exists() { diff --git a/crates/api/src/dto/mod.rs b/crates/api/src/dto/mod.rs index 257d5f0d..8d4c5322 100644 --- a/crates/api/src/dto/mod.rs +++ b/crates/api/src/dto/mod.rs @@ -9,3 +9,4 @@ pub mod plugin; pub mod skill; pub mod sub_agent; pub mod transfer; +pub mod usage; diff --git a/crates/api/src/dto/usage.rs b/crates/api/src/dto/usage.rs new file mode 100644 index 00000000..cdaa273b --- /dev/null +++ b/crates/api/src/dto/usage.rs @@ -0,0 +1,72 @@ +use serde::Serialize; +use ts_rs::TS; + +/// Unified usage report across agents, returned by `GET /api/v1/usage/summary`. +/// +/// ccusage emits a different JSON shape per agent (claude has cache-creation, +/// codex has reasoning, cost keys differ); this DTO is the normalized shape the +/// frontend consumes. The mapping from each ccusage shape lives in +/// `routes::usage`. +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct UsageReportDto { + pub agents: Vec, + pub generated_at: String, + pub ccusage_version: String, + /// Non-fatal notes (e.g. an agent had no data, a model had no pricing). + pub warnings: Vec, +} + +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct AgentUsageDto { + /// "claude" | "codex" + pub agent: String, + pub days: Vec, + pub totals: UsageTotalsDto, +} + +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct UsageDayDto { + /// "YYYY-MM-DD" + pub date: String, + pub input_tokens: u64, + pub output_tokens: u64, + /// Cache write tokens (claude only; 0 for codex). + pub cache_creation_tokens: u64, + /// Cache read tokens (claude `cacheRead`, codex `cachedInput`). + pub cache_read_tokens: u64, + /// Reasoning tokens (codex only; 0 for claude). + pub reasoning_tokens: u64, + pub total_tokens: u64, + /// USD cost. `None` when ccusage could not price it (unknown model). + pub cost_usd: Option, + pub models: Vec, +} + +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct UsageModelDto { + pub model: String, + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_creation_tokens: u64, + pub cache_read_tokens: u64, + pub reasoning_tokens: u64, + pub total_tokens: u64, + /// USD cost. `None` for codex (its per-model map carries no cost). + pub cost_usd: Option, +} + +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct UsageTotalsDto { + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_creation_tokens: u64, + pub cache_read_tokens: u64, + pub reasoning_tokens: u64, + pub total_tokens: u64, + pub cost_usd: Option, +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 6b6ead85..f1edd16c 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -22,6 +22,8 @@ pub(crate) const CREATE_NO_WINDOW: u32 = 0x0800_0000; pub struct ApiOptions { pub port: u16, pub app_data_dir: Option, + /// Path to the bundled `ccusage` sidecar; `None` falls back to env/PATH. + pub ccusage_bin: Option, } impl ApiOptions { @@ -29,6 +31,7 @@ impl ApiOptions { Self { port, app_data_dir: None, + ccusage_bin: None, } } } @@ -92,6 +95,7 @@ impl Fairing for ApiLogFairing { fn build_rocket( config: rocket::Config, app_data_dir: PathBuf, + ccusage_bin: Option, ) -> rocket::Rocket { let cors = rocket_cors::CorsOptions { allowed_origins: rocket_cors::AllOrSome::All, @@ -122,6 +126,7 @@ fn build_rocket( sessions: std::sync::Mutex::new(std::collections::HashMap::new()), }) .manage(crate::state::InferenceProviderState { app_data_dir }) + .manage(crate::state::UsageState { ccusage_bin }) .mount( "/api/v1", routes![ @@ -222,6 +227,7 @@ fn build_rocket( routes::plugins::cli_status, routes::plugins::prune_plugins, routes::plugins::validate_plugin, + routes::usage::usage_summary, ], ) .register( @@ -245,7 +251,7 @@ pub async fn start(options: ApiOptions) -> Result<(), rocket::Error> { log_level: rocket::config::LogLevel::Normal, ..rocket::Config::default() }; - build_rocket(config, app_data_dir) + build_rocket(config, app_data_dir, options.ccusage_bin) .launch() .await .inspect(|_rocket| { @@ -269,6 +275,7 @@ mod tests { let client = Client::tracked(build_rocket( rocket::Config::default(), default_app_data_dir(), + None, )) .expect("client"); diff --git a/crates/api/src/routes/mod.rs b/crates/api/src/routes/mod.rs index 3e876e9b..cd3c8815 100644 --- a/crates/api/src/routes/mod.rs +++ b/crates/api/src/routes/mod.rs @@ -8,6 +8,7 @@ pub mod mcps; pub mod plugins; pub mod skills; pub mod sub_agents; +pub mod usage; use aghub_core::{ create_adapter, manager::ConfigManager, models::ResourceScope, diff --git a/crates/api/src/routes/usage.rs b/crates/api/src/routes/usage.rs new file mode 100644 index 00000000..ed6aa635 --- /dev/null +++ b/crates/api/src/routes/usage.rs @@ -0,0 +1,340 @@ +//! Usage monitoring: shells out to the bundled `ccusage` binary and normalizes +//! its per-agent `--json` output into the unified [`UsageReportDto`]. +//! +//! ccusage is reused as-is (it owns parsing, dedup, pricing, format tracking); +//! this module is only the adapter layer. Claude and Codex emit different JSON +//! shapes, so each has its own deserialization struct and mapping function. + +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; +use std::time::Duration; + +use rocket::serde::json::Json; +use rocket::State; +use serde::Deserialize; + +use crate::dto::usage::{ + AgentUsageDto, UsageDayDto, UsageModelDto, UsageReportDto, UsageTotalsDto, +}; +use crate::error::ApiError; +use crate::state::UsageState; + +const CCUSAGE_TIMEOUT: Duration = Duration::from_secs(30); + +/// Locate the ccusage binary. Preference order: the sidecar path injected by +/// the desktop shell (`UsageState`), then the `AGHUB_CCUSAGE_BIN` env var (dev), +/// then `ccusage` on `PATH`. +fn ccusage_bin(state: &UsageState) -> OsString { + if let Some(path) = &state.ccusage_bin { + return path.clone().into_os_string(); + } + std::env::var_os("AGHUB_CCUSAGE_BIN") + .unwrap_or_else(|| OsString::from("ccusage")) +} + +async fn run_ccusage( + bin: &OsStr, + args: Vec, +) -> Result, ApiError> { + 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() + )) + })?; + if !output.status.success() { + return Err(ApiError::internal(format!( + "ccusage {:?} exited with {}: {}", + args, + output.status, + String::from_utf8_lossy(&output.stderr) + ))); + } + Ok(output.stdout) +} + +/// `ccusage --version` → e.g. "ccusage 20.0.6"; "unknown" if it can't be read. +async fn ccusage_version(bin: &OsStr) -> String { + run_ccusage(bin, vec!["--version".to_string()]) + .await + .ok() + .and_then(|out| String::from_utf8(out).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) +} + +// ---- ccusage `claude daily --json` shape ----------------------------------- + +#[derive(Deserialize)] +struct CcClaudeReport { + daily: Vec, + totals: CcClaudeTotals, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CcClaudeDay { + date: String, + input_tokens: u64, + output_tokens: u64, + cache_creation_tokens: u64, + cache_read_tokens: u64, + total_tokens: u64, + total_cost: f64, + #[serde(default)] + model_breakdowns: Vec, +} + +#[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, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CcClaudeTotals { + input_tokens: u64, + output_tokens: u64, + cache_creation_tokens: u64, + cache_read_tokens: u64, + total_tokens: u64, + total_cost: f64, +} + +// ---- ccusage `codex daily --json` shape ------------------------------------ + +#[derive(Deserialize)] +struct CcCodexReport { + daily: Vec, + totals: CcCodexTotals, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CcCodexDay { + date: String, + input_tokens: u64, + cached_input_tokens: u64, + output_tokens: u64, + reasoning_output_tokens: u64, + total_tokens: u64, + #[serde(rename = "costUSD")] + cost_usd: f64, + #[serde(default)] + models: HashMap, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CcCodexModel { + input_tokens: u64, + cached_input_tokens: u64, + output_tokens: u64, + reasoning_output_tokens: u64, + total_tokens: u64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CcCodexTotals { + input_tokens: u64, + cached_input_tokens: u64, + output_tokens: u64, + reasoning_output_tokens: u64, + total_tokens: u64, + #[serde(rename = "costUSD")] + cost_usd: f64, +} + +// ---- normalization --------------------------------------------------------- +// +// Decisions baked in here (worth a review): +// * Claude has no reasoning tokens -> reasoning_tokens = 0. +// * Codex has no cache-creation -> cache_creation_tokens = 0, and its +// `cachedInputTokens` maps onto the unified `cache_read_tokens`. +// * Codex's per-model map carries no cost, so per-model cost_usd = None +// (only the day/total cost is known); Claude's per-model cost is Some. +// * Claude's per-model breakdown has no totalTokens field, so we sum it. + +fn claude_to_agent(report: CcClaudeReport) -> AgentUsageDto { + let days = report + .daily + .into_iter() + .map(|d| UsageDayDto { + date: d.date, + input_tokens: d.input_tokens, + output_tokens: d.output_tokens, + cache_creation_tokens: d.cache_creation_tokens, + cache_read_tokens: d.cache_read_tokens, + reasoning_tokens: 0, + total_tokens: d.total_tokens, + cost_usd: Some(d.total_cost), + models: d + .model_breakdowns + .into_iter() + .map(|m| UsageModelDto { + total_tokens: m.input_tokens + + m.output_tokens + m.cache_creation_tokens + + m.cache_read_tokens, + model: m.model_name, + input_tokens: m.input_tokens, + output_tokens: m.output_tokens, + cache_creation_tokens: m.cache_creation_tokens, + cache_read_tokens: m.cache_read_tokens, + reasoning_tokens: 0, + cost_usd: Some(m.cost), + }) + .collect(), + }) + .collect(); + + AgentUsageDto { + agent: "claude".to_string(), + days, + totals: UsageTotalsDto { + input_tokens: report.totals.input_tokens, + output_tokens: report.totals.output_tokens, + cache_creation_tokens: report.totals.cache_creation_tokens, + cache_read_tokens: report.totals.cache_read_tokens, + reasoning_tokens: 0, + total_tokens: report.totals.total_tokens, + cost_usd: Some(report.totals.total_cost), + }, + } +} + +fn codex_to_agent(report: CcCodexReport) -> AgentUsageDto { + let days = report + .daily + .into_iter() + .map(|d| UsageDayDto { + date: d.date, + input_tokens: d.input_tokens, + output_tokens: d.output_tokens, + cache_creation_tokens: 0, + cache_read_tokens: d.cached_input_tokens, + reasoning_tokens: d.reasoning_output_tokens, + total_tokens: d.total_tokens, + cost_usd: Some(d.cost_usd), + models: d + .models + .into_iter() + .map(|(name, m)| UsageModelDto { + model: name, + input_tokens: m.input_tokens, + output_tokens: m.output_tokens, + cache_creation_tokens: 0, + cache_read_tokens: m.cached_input_tokens, + reasoning_tokens: m.reasoning_output_tokens, + total_tokens: m.total_tokens, + cost_usd: None, + }) + .collect(), + }) + .collect(); + + AgentUsageDto { + agent: "codex".to_string(), + days, + totals: UsageTotalsDto { + input_tokens: report.totals.input_tokens, + output_tokens: report.totals.output_tokens, + cache_creation_tokens: 0, + cache_read_tokens: report.totals.cached_input_tokens, + reasoning_tokens: report.totals.reasoning_output_tokens, + total_tokens: report.totals.total_tokens, + cost_usd: Some(report.totals.cost_usd), + }, + } +} + +async fn fetch_claude_usage( + bin: &OsStr, + args: Vec, +) -> Result { + let raw = run_ccusage(bin, args).await.map_err(|e| e.body.error)?; + let report: CcClaudeReport = serde_json::from_slice(&raw) + .map_err(|e| format!("parse claude usage json: {e}"))?; + Ok(claude_to_agent(report)) +} + +async fn fetch_codex_usage( + bin: &OsStr, + args: Vec, +) -> Result { + let raw = run_ccusage(bin, args).await.map_err(|e| e.body.error)?; + let report: CcCodexReport = serde_json::from_slice(&raw) + .map_err(|e| format!("parse codex usage json: {e}"))?; + Ok(codex_to_agent(report)) +} + +/// `GET /api/v1/usage/summary` — daily token/cost usage for Claude and Codex. +/// +/// Degrades gracefully: if one agent's ccusage call fails (not installed, no +/// data, malformed output) it is reported in `warnings` instead of failing the +/// whole request, so the home page can still render whatever is available. +#[get("/usage/summary?&&")] +pub async fn usage_summary( + usage: &State, + since: Option, + until: Option, + timezone: Option, +) -> Json { + let bin = ccusage_bin(usage); + let build_args = |agent: &str| -> Vec { + let mut args = vec![ + agent.to_string(), + "daily".to_string(), + "--json".to_string(), + "--offline".to_string(), + ]; + if let Some(s) = &since { + args.push("--since".to_string()); + args.push(s.clone()); + } + if let Some(u) = &until { + args.push("--until".to_string()); + args.push(u.clone()); + } + if let Some(tz) = &timezone { + args.push("--timezone".to_string()); + args.push(tz.clone()); + } + args + }; + + let (version, claude_res, codex_res) = tokio::join!( + ccusage_version(&bin), + fetch_claude_usage(&bin, build_args("claude")), + fetch_codex_usage(&bin, build_args("codex")), + ); + + let mut agents = Vec::new(); + let mut warnings = Vec::new(); + match claude_res { + Ok(agent) => agents.push(agent), + Err(e) => warnings.push(format!("claude usage unavailable: {e}")), + } + match codex_res { + Ok(agent) => agents.push(agent), + Err(e) => warnings.push(format!("codex usage unavailable: {e}")), + } + + Json(UsageReportDto { + agents, + generated_at: chrono::Utc::now().to_rfc3339(), + ccusage_version: version, + warnings, + }) +} diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index 85a519d9..6acd714b 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -24,3 +24,10 @@ pub struct GitCloneSessions { pub struct InferenceProviderState { pub app_data_dir: PathBuf, } + +/// Path to the bundled `ccusage` sidecar binary, injected by the desktop shell. +/// `None` in dev / standalone server: usage routes fall back to the +/// `AGHUB_CCUSAGE_BIN` env var, then to `ccusage` on `PATH`. +pub struct UsageState { + pub ccusage_bin: Option, +} diff --git a/crates/desktop/src-tauri/src/commands/server.rs b/crates/desktop/src-tauri/src/commands/server.rs index 0b1d9220..026be47e 100644 --- a/crates/desktop/src-tauri/src/commands/server.rs +++ b/crates/desktop/src-tauri/src/commands/server.rs @@ -1,6 +1,7 @@ use crate::AppState; use aghub_api::{start, ApiOptions}; -use log::{debug, error, info}; +use log::{debug, error, info, warn}; +use std::path::PathBuf; use tauri::Manager; fn find_available_port() -> Result { @@ -10,12 +11,42 @@ fn find_available_port() -> Result { Ok(port) } +/// Resolve the bundled `ccusage` sidecar path to hand to the embedded API. +/// +/// Resolution order: +/// 1. `AGHUB_CCUSAGE_BIN` env var — explicit override, always wins (used in dev +/// and as a prod escape hatch). +/// 2. dev build (`tauri dev` / `cargo run`) — no sidecar is staged next to the +/// executable, so return `None` and let the API fall back to `ccusage` on PATH. +/// 3. packaged build — the sidecar ships beside the main executable as `ccusage` +/// (Tauri strips the `-` suffix at bundle time). We trust the computed +/// path: if the fetch step was skipped the API surfaces a clear spawn error +/// rather than us silently masking it with a PATH fallback. +/// +/// `tauri::process::current_binary` is preferred over std's `current_exe` +/// because it returns the real path under AppImage (not the temp mountpoint). +fn resolve_ccusage_bin(app: &tauri::AppHandle) -> Option { + if let Some(path) = std::env::var_os("AGHUB_CCUSAGE_BIN") { + return Some(PathBuf::from(path)); + } + if cfg!(debug_assertions) { + return None; + } + tauri::process::current_binary(&app.env()) + .ok() + .and_then(|exe| exe.parent().map(|dir| dir.join("ccusage"))) +} + #[tauri::command] pub async fn start_server( state: tauri::State<'_, AppState>, app: tauri::AppHandle, ) -> Result { let app_data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let ccusage_bin = resolve_ccusage_bin(&app); + if ccusage_bin.is_none() { + warn!("ccusage sidecar not resolved; usage monitoring will fall back to AGHUB_CCUSAGE_BIN / PATH"); + } let port = { let mut guard = state.port.lock().unwrap(); if let Some(port) = *guard { @@ -35,6 +66,7 @@ pub async fn start_server( if let Err(error) = start(ApiOptions { port, app_data_dir: Some(app_data_dir), + ccusage_bin, }) .await { diff --git a/crates/desktop/src-tauri/tauri.conf.json b/crates/desktop/src-tauri/tauri.conf.json index 444eb6a1..3da49245 100644 --- a/crates/desktop/src-tauri/tauri.conf.json +++ b/crates/desktop/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "build": { "beforeDevCommand": "bun run dev", "devUrl": "http://localhost:1420", - "beforeBuildCommand": "bun run build", + "beforeBuildCommand": "node ../../scripts/fetch-ccusage.mjs && bun run build", "frontendDist": "../dist" }, "app": { @@ -34,6 +34,7 @@ "bundle": { "active": true, "targets": "all", + "externalBin": ["binaries/ccusage"], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/crates/desktop/src/generated/dto/AgentUsageDto.ts b/crates/desktop/src/generated/dto/AgentUsageDto.ts new file mode 100644 index 00000000..fbe06f67 --- /dev/null +++ b/crates/desktop/src/generated/dto/AgentUsageDto.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UsageDayDto } from "./UsageDayDto"; +import type { UsageTotalsDto } from "./UsageTotalsDto"; + +export type AgentUsageDto = { + /** + * "claude" | "codex" + */ + agent: string; + days: Array; + totals: UsageTotalsDto; +}; diff --git a/crates/desktop/src/generated/dto/UsageDayDto.ts b/crates/desktop/src/generated/dto/UsageDayDto.ts new file mode 100644 index 00000000..aba1365c --- /dev/null +++ b/crates/desktop/src/generated/dto/UsageDayDto.ts @@ -0,0 +1,29 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UsageModelDto } from "./UsageModelDto"; + +export type UsageDayDto = { + /** + * "YYYY-MM-DD" + */ + date: string; + input_tokens: number; + output_tokens: number; + /** + * Cache write tokens (claude only; 0 for codex). + */ + cache_creation_tokens: number; + /** + * Cache read tokens (claude `cacheRead`, codex `cachedInput`). + */ + cache_read_tokens: number; + /** + * Reasoning tokens (codex only; 0 for claude). + */ + reasoning_tokens: number; + total_tokens: number; + /** + * USD cost. `None` when ccusage could not price it (unknown model). + */ + cost_usd: number | null; + models: Array; +}; diff --git a/crates/desktop/src/generated/dto/UsageModelDto.ts b/crates/desktop/src/generated/dto/UsageModelDto.ts new file mode 100644 index 00000000..3ec53ee6 --- /dev/null +++ b/crates/desktop/src/generated/dto/UsageModelDto.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UsageModelDto = { + model: string; + input_tokens: number; + output_tokens: number; + cache_creation_tokens: number; + cache_read_tokens: number; + reasoning_tokens: number; + total_tokens: number; + /** + * USD cost. `None` for codex (its per-model map carries no cost). + */ + cost_usd: number | null; +}; diff --git a/crates/desktop/src/generated/dto/UsageReportDto.ts b/crates/desktop/src/generated/dto/UsageReportDto.ts new file mode 100644 index 00000000..4aa646c6 --- /dev/null +++ b/crates/desktop/src/generated/dto/UsageReportDto.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgentUsageDto } from "./AgentUsageDto"; + +/** + * Unified usage report across agents, returned by `GET /api/v1/usage/summary`. + * + * ccusage emits a different JSON shape per agent (claude has cache-creation, + * codex has reasoning, cost keys differ); this DTO is the normalized shape the + * frontend consumes. The mapping from each ccusage shape lives in + * `routes::usage`. + */ +export type UsageReportDto = { + agents: Array; + generated_at: string; + ccusage_version: string; + /** + * Non-fatal notes (e.g. an agent had no data, a model had no pricing). + */ + warnings: Array; +}; diff --git a/crates/desktop/src/generated/dto/UsageTotalsDto.ts b/crates/desktop/src/generated/dto/UsageTotalsDto.ts new file mode 100644 index 00000000..6c121100 --- /dev/null +++ b/crates/desktop/src/generated/dto/UsageTotalsDto.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UsageTotalsDto = { + input_tokens: number; + output_tokens: number; + cache_creation_tokens: number; + cache_read_tokens: number; + reasoning_tokens: number; + total_tokens: number; + cost_usd: number | null; +}; diff --git a/crates/desktop/src/generated/dto/index.ts b/crates/desktop/src/generated/dto/index.ts index d45fdf08..238f0a94 100644 --- a/crates/desktop/src/generated/dto/index.ts +++ b/crates/desktop/src/generated/dto/index.ts @@ -5,6 +5,7 @@ export type { AgentProviderMatchedInferenceProviderResponse } from "./AgentProvi export type { AgentProviderModelResponse } from "./AgentProviderModelResponse"; export type { AgentProviderResponse } from "./AgentProviderResponse"; export type { AgentProviderSourceDto } from "./AgentProviderSourceDto"; +export type { AgentUsageDto } from "./AgentUsageDto"; export type { CCMarketplaceAddRequest } from "./CCMarketplaceAddRequest"; export type { CCMarketplaceEntryResponse } from "./CCMarketplaceEntryResponse"; export type { CCMarketplaceListResponse } from "./CCMarketplaceListResponse"; @@ -109,4 +110,8 @@ export type { UpdateInferenceProviderRequest } from "./UpdateInferenceProviderRe export type { UpdateMcpRequest } from "./UpdateMcpRequest"; export type { UpdateSkillRequest } from "./UpdateSkillRequest"; export type { UpdateSubAgentRequest } from "./UpdateSubAgentRequest"; +export type { UsageDayDto } from "./UsageDayDto"; +export type { UsageModelDto } from "./UsageModelDto"; +export type { UsageReportDto } from "./UsageReportDto"; +export type { UsageTotalsDto } from "./UsageTotalsDto"; export type { ValidationError } from "./ValidationError"; diff --git a/scripts/fetch-ccusage.mjs b/scripts/fetch-ccusage.mjs new file mode 100644 index 00000000..c819d172 --- /dev/null +++ b/scripts/fetch-ccusage.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +// Fetch the prebuilt ccusage binary for the current Rust target triple and +// stage it as a Tauri sidecar at crates/desktop/src-tauri/binaries/ccusage-. +// +// ccusage ships per-platform native binaries via npm (@ccusage/ccusage-); +// each package contains a standalone executable at bin/ccusage (no node needed). +// We grab only the binary for the host triple — cross-platform CI runners each +// stage their own. Run automatically from tauri's beforeBuildCommand. +// +// Zero npm dependencies: download with built-in fetch, unpack with the system +// `tar` (bsdtar on macOS/Windows, GNU tar on Linux — both read gzip from stdin). + +import { spawn, execFileSync } from "node:child_process"; +import { chmod, mkdir, access, rename, stat } from "node:fs/promises"; +import { createWriteStream } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { pipeline } from "node:stream/promises"; + +const CCUSAGE_VERSION = "20.0.6"; + +// Rust target triple -> npm platform package suffix. +const TRIPLE_TO_PLATFORM = { + "aarch64-apple-darwin": "darwin-arm64", + "x86_64-apple-darwin": "darwin-x64", + "aarch64-unknown-linux-gnu": "linux-arm64", + "x86_64-unknown-linux-gnu": "linux-x64", + "aarch64-pc-windows-msvc": "win32-arm64", + "x86_64-pc-windows-msvc": "win32-x64", +}; + +function hostTriple() { + const out = execFileSync("rustc", ["-Vv"], { encoding: "utf8" }); + const line = out.split("\n").find((l) => l.startsWith("host: ")); + if (!line) + throw new Error("could not determine host triple from `rustc -Vv`"); + return line.slice("host: ".length).trim(); +} + +async function exists(path) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +// Pipe the gzipped tarball through `tar`, extracting a single member to a file. +// `tar -xzO` writes the member to stdout; we pipeline that into destPath and +// await both the process exit and the file write completing. +async function extractMemberToFile(tarballBytes, member, destPath) { + const tar = spawn("tar", ["-xzO", "-f", "-", member], { + stdio: ["pipe", "pipe", "inherit"], + }); + const exited = new Promise((resolve, reject) => { + tar.on("error", reject); + tar.on("close", (code) => + code === 0 ? resolve() : reject(new Error(`tar exited ${code}`)), + ); + }); + tar.stdin.end(tarballBytes); + await Promise.all([ + pipeline(tar.stdout, createWriteStream(destPath)), + exited, + ]); + if ((await stat(destPath)).size === 0) { + throw new Error(`member not found in tarball: ${member}`); + } +} + +async function main() { + const triple = process.env.CCUSAGE_TARGET_TRIPLE ?? hostTriple(); + const platform = TRIPLE_TO_PLATFORM[triple]; + if (!platform) { + throw new Error( + `no ccusage prebuilt binary for target triple '${triple}'. ` + + `Known: ${Object.keys(TRIPLE_TO_PLATFORM).join(", ")}`, + ); + } + + const isWindows = triple.includes("windows"); + const scriptDir = dirname(fileURLToPath(import.meta.url)); + const binariesDir = join( + scriptDir, + "..", + "crates", + "desktop", + "src-tauri", + "binaries", + ); + const ext = isWindows ? ".exe" : ""; + const dest = join(binariesDir, `ccusage-${triple}${ext}`); + + if (await exists(dest)) { + console.log(`[fetch-ccusage] already present: ${dest}`); + return; + } + + await mkdir(binariesDir, { recursive: true }); + + 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()); + + // npm tarball layout: package/bin/ccusage(.exe). + const member = `package/bin/ccusage${ext}`; + const tmp = `${dest}.tmp`; + await extractMemberToFile(tarballBytes, member, tmp); + await rename(tmp, dest); + if (!isWindows) await chmod(dest, 0o755); + console.log(`[fetch-ccusage] staged sidecar: ${dest}`); +} + +main().catch((err) => { + console.error(`[fetch-ccusage] ${err.message}`); + process.exit(1); +}); From ee4aa22510e293395ea4565e111aba683b73593a Mon Sep 17 00:00:00 2001 From: Flacier Date: Mon, 1 Jun 2026 05:34:36 +0800 Subject: [PATCH 02/13] fix(ci): fetch ccusage sidecar before compiling the desktop crate - 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 --- justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/justfile b/justfile index 6210c337..ad05ac88 100644 --- a/justfile +++ b/justfile @@ -15,7 +15,7 @@ dev: cargo build -p aghub-cli # Run all tests -test: +test: _fetch-ccusage cargo test --workspace # Run integration tests only From 3a5c7cd2d7fa48386ce53c065bfe9edcdf350b13 Mon Sep 17 00:00:00 2001 From: Flacier Date: Mon, 1 Jun 2026 05:46:04 +0800 Subject: [PATCH 03/13] fix(ci): define the _fetch-ccusage recipe the lint/test tasks depend on 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). --- justfile | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/justfile b/justfile index ad05ac88..ac4c69f7 100644 --- a/justfile +++ b/justfile @@ -14,6 +14,13 @@ build: dev: cargo build -p aghub-cli +# Fetch the ccusage sidecar binary for the current target triple (idempotent; +# skips if already present). Required before compiling the desktop crate: +# tauri-build validates the externalBin path at build time and the binary is +# gitignored, so CI / fresh clones must fetch it first. +_fetch-ccusage: + node scripts/fetch-ccusage.mjs + # Run all tests test: _fetch-ccusage cargo test --workspace @@ -32,7 +39,7 @@ fmt: bun run format # Run clippy linter -lint: +lint: _fetch-ccusage cargo clippy --workspace -- -D warnings cd ./crates/desktop && nr lint From f5259557b0eb5a08e6db3468af4a8c3477ae8676 Mon Sep 17 00:00:00 2001 From: Flacier Date: Wed, 3 Jun 2026 01:29:05 +0800 Subject: [PATCH 04/13] build(desktop): fetch ccusage sidecar in build.rs - 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 --- Cargo.lock | 38 +++++++ crates/desktop/src-tauri/Cargo.toml | 3 + crates/desktop/src-tauri/build.rs | 93 ++++++++++++++++- crates/desktop/src-tauri/tauri.conf.json | 2 +- justfile | 11 +- scripts/fetch-ccusage.mjs | 126 ----------------------- 6 files changed, 135 insertions(+), 138 deletions(-) delete mode 100644 scripts/fetch-ccusage.mjs diff --git a/Cargo.lock b/Cargo.lock index 935c466f..021df60f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,10 +26,12 @@ dependencies = [ "aghub-api", "dotenvy", "fix-path-env", + "flate2", "log", "posthog-rs", "serde", "serde_json", + "tar", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", @@ -44,6 +46,7 @@ dependencies = [ "tempfile", "time", "tokio", + "ureq", "uuid", "zip 8.6.0", ] @@ -6385,6 +6388,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -8739,6 +8743,22 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -9184,6 +9204,24 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" diff --git a/crates/desktop/src-tauri/Cargo.toml b/crates/desktop/src-tauri/Cargo.toml index 13cdacdd..b69e71f7 100644 --- a/crates/desktop/src-tauri/Cargo.toml +++ b/crates/desktop/src-tauri/Cargo.toml @@ -16,6 +16,9 @@ crate-type = [ "staticlib", "cdylib", "rlib" ] [build-dependencies] dotenvy = "0.15.7" tauri-build = { version = "2", features = [ ] } +ureq = "2" +flate2 = "1.0" +tar = "0.4" [dependencies] tauri = { version = "2", features = [ ] } diff --git a/crates/desktop/src-tauri/build.rs b/crates/desktop/src-tauri/build.rs index ac6f74a1..bc650a87 100644 --- a/crates/desktop/src-tauri/build.rs +++ b/crates/desktop/src-tauri/build.rs @@ -1,4 +1,92 @@ -use std::path::Path; +use std::env; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use flate2::read::GzDecoder; + +// Download ccusage's prebuilt npm binary and stage it as the Tauri sidecar. +const CCUSAGE_VERSION: &str = "20.0.6"; + +fn ccusage_platform(triple: &str) -> Option<&'static str> { + Some(match triple { + "aarch64-apple-darwin" => "darwin-arm64", + "x86_64-apple-darwin" => "darwin-x64", + "aarch64-unknown-linux-gnu" => "linux-arm64", + "x86_64-unknown-linux-gnu" => "linux-x64", + "aarch64-pc-windows-msvc" => "win32-arm64", + "x86_64-pc-windows-msvc" => "win32-x64", + _ => return None, + }) +} + +fn fetch_ccusage_sidecar() { + let triple = env::var("TARGET").expect("cargo sets TARGET"); + let platform = ccusage_platform(&triple).unwrap_or_else(|| { + panic!( + "no ccusage prebuilt binary for target triple '{triple}'. \ + Add the mapping in build.rs if a package now exists." + ) + }); + + let is_windows = triple.contains("windows"); + let ext = if is_windows { ".exe" } else { "" }; + + let binaries_dir = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("binaries"); + let dest = binaries_dir.join(format!("ccusage-{triple}{ext}")); + + // Filename is version-independent, so bumping CCUSAGE_VERSION needs binaries/ cleared. + if dest.exists() { + return; + } + + fs::create_dir_all(&binaries_dir).expect("create binaries dir"); + + 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"); + + // npm tarball layout: package/bin/ccusage(.exe). + let member = format!("package/bin/ccusage{ext}"); + let mut archive = tar::Archive::new(GzDecoder::new(&tarball[..])); + let mut staged = false; + for entry in archive.entries().expect("read tar entries") { + let mut entry = entry.expect("read tar entry"); + let path = entry.path().expect("tar entry path"); + if path.to_string_lossy().replace('\\', "/") != member { + continue; + } + let tmp = dest.with_extension("tmp"); + io::copy( + &mut entry, + &mut fs::File::create(&tmp).expect("create temp sidecar"), + ) + .expect("write sidecar"); + fs::rename(&tmp, &dest).expect("rename sidecar into place"); + staged = true; + break; + } + if !staged { + panic!("member not found in tarball: {member}"); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&dest, fs::Permissions::from_mode(0o755)) + .expect("chmod sidecar"); + } +} fn main() { // Load POSTHOG_KEY / POSTHOG_HOST from the desktop crate's .env so @@ -16,5 +104,6 @@ fn main() { } } - tauri_build::build() + fetch_ccusage_sidecar(); + tauri_build::build(); } diff --git a/crates/desktop/src-tauri/tauri.conf.json b/crates/desktop/src-tauri/tauri.conf.json index 3da49245..8888b8c6 100644 --- a/crates/desktop/src-tauri/tauri.conf.json +++ b/crates/desktop/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "build": { "beforeDevCommand": "bun run dev", "devUrl": "http://localhost:1420", - "beforeBuildCommand": "node ../../scripts/fetch-ccusage.mjs && bun run build", + "beforeBuildCommand": "bun run build", "frontendDist": "../dist" }, "app": { diff --git a/justfile b/justfile index ac4c69f7..6210c337 100644 --- a/justfile +++ b/justfile @@ -14,15 +14,8 @@ build: dev: cargo build -p aghub-cli -# Fetch the ccusage sidecar binary for the current target triple (idempotent; -# skips if already present). Required before compiling the desktop crate: -# tauri-build validates the externalBin path at build time and the binary is -# gitignored, so CI / fresh clones must fetch it first. -_fetch-ccusage: - node scripts/fetch-ccusage.mjs - # Run all tests -test: _fetch-ccusage +test: cargo test --workspace # Run integration tests only @@ -39,7 +32,7 @@ fmt: bun run format # Run clippy linter -lint: _fetch-ccusage +lint: cargo clippy --workspace -- -D warnings cd ./crates/desktop && nr lint diff --git a/scripts/fetch-ccusage.mjs b/scripts/fetch-ccusage.mjs deleted file mode 100644 index c819d172..00000000 --- a/scripts/fetch-ccusage.mjs +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env node -// Fetch the prebuilt ccusage binary for the current Rust target triple and -// stage it as a Tauri sidecar at crates/desktop/src-tauri/binaries/ccusage-. -// -// ccusage ships per-platform native binaries via npm (@ccusage/ccusage-); -// each package contains a standalone executable at bin/ccusage (no node needed). -// We grab only the binary for the host triple — cross-platform CI runners each -// stage their own. Run automatically from tauri's beforeBuildCommand. -// -// Zero npm dependencies: download with built-in fetch, unpack with the system -// `tar` (bsdtar on macOS/Windows, GNU tar on Linux — both read gzip from stdin). - -import { spawn, execFileSync } from "node:child_process"; -import { chmod, mkdir, access, rename, stat } from "node:fs/promises"; -import { createWriteStream } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { pipeline } from "node:stream/promises"; - -const CCUSAGE_VERSION = "20.0.6"; - -// Rust target triple -> npm platform package suffix. -const TRIPLE_TO_PLATFORM = { - "aarch64-apple-darwin": "darwin-arm64", - "x86_64-apple-darwin": "darwin-x64", - "aarch64-unknown-linux-gnu": "linux-arm64", - "x86_64-unknown-linux-gnu": "linux-x64", - "aarch64-pc-windows-msvc": "win32-arm64", - "x86_64-pc-windows-msvc": "win32-x64", -}; - -function hostTriple() { - const out = execFileSync("rustc", ["-Vv"], { encoding: "utf8" }); - const line = out.split("\n").find((l) => l.startsWith("host: ")); - if (!line) - throw new Error("could not determine host triple from `rustc -Vv`"); - return line.slice("host: ".length).trim(); -} - -async function exists(path) { - try { - await access(path); - return true; - } catch { - return false; - } -} - -// Pipe the gzipped tarball through `tar`, extracting a single member to a file. -// `tar -xzO` writes the member to stdout; we pipeline that into destPath and -// await both the process exit and the file write completing. -async function extractMemberToFile(tarballBytes, member, destPath) { - const tar = spawn("tar", ["-xzO", "-f", "-", member], { - stdio: ["pipe", "pipe", "inherit"], - }); - const exited = new Promise((resolve, reject) => { - tar.on("error", reject); - tar.on("close", (code) => - code === 0 ? resolve() : reject(new Error(`tar exited ${code}`)), - ); - }); - tar.stdin.end(tarballBytes); - await Promise.all([ - pipeline(tar.stdout, createWriteStream(destPath)), - exited, - ]); - if ((await stat(destPath)).size === 0) { - throw new Error(`member not found in tarball: ${member}`); - } -} - -async function main() { - const triple = process.env.CCUSAGE_TARGET_TRIPLE ?? hostTriple(); - const platform = TRIPLE_TO_PLATFORM[triple]; - if (!platform) { - throw new Error( - `no ccusage prebuilt binary for target triple '${triple}'. ` + - `Known: ${Object.keys(TRIPLE_TO_PLATFORM).join(", ")}`, - ); - } - - const isWindows = triple.includes("windows"); - const scriptDir = dirname(fileURLToPath(import.meta.url)); - const binariesDir = join( - scriptDir, - "..", - "crates", - "desktop", - "src-tauri", - "binaries", - ); - const ext = isWindows ? ".exe" : ""; - const dest = join(binariesDir, `ccusage-${triple}${ext}`); - - if (await exists(dest)) { - console.log(`[fetch-ccusage] already present: ${dest}`); - return; - } - - await mkdir(binariesDir, { recursive: true }); - - 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()); - - // npm tarball layout: package/bin/ccusage(.exe). - const member = `package/bin/ccusage${ext}`; - const tmp = `${dest}.tmp`; - await extractMemberToFile(tarballBytes, member, tmp); - await rename(tmp, dest); - if (!isWindows) await chmod(dest, 0o755); - console.log(`[fetch-ccusage] staged sidecar: ${dest}`); -} - -main().catch((err) => { - console.error(`[fetch-ccusage] ${err.message}`); - process.exit(1); -}); From eb081ecb2c9bbc63c6783fb0a2e26d006a7f923c Mon Sep 17 00:00:00 2001 From: Flacier Date: Wed, 3 Jun 2026 02:34:50 +0800 Subject: [PATCH 05/13] feat(usage): add Claude/Codex remaining-quota limits endpoint - 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. --- crates/api/src/bin/export-dto.rs | 7 +- crates/api/src/dto/usage.rs | 36 +++ crates/api/src/lib.rs | 1 + crates/api/src/routes/usage.rs | 255 +++++++++++++++++- .../src/generated/dto/AgentLimitsDto.ts | 10 + .../src/generated/dto/LimitWindowDto.ts | 20 ++ .../src/generated/dto/UsageLimitsReportDto.ts | 19 ++ crates/desktop/src/generated/dto/index.ts | 3 + 8 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 crates/desktop/src/generated/dto/AgentLimitsDto.ts create mode 100644 crates/desktop/src/generated/dto/LimitWindowDto.ts create mode 100644 crates/desktop/src/generated/dto/UsageLimitsReportDto.ts diff --git a/crates/api/src/bin/export-dto.rs b/crates/api/src/bin/export-dto.rs index 944a8db8..5f04cd17 100644 --- a/crates/api/src/bin/export-dto.rs +++ b/crates/api/src/bin/export-dto.rs @@ -5,7 +5,8 @@ use std::{ }; use aghub_api::dto::usage::{ - AgentUsageDto, UsageDayDto, UsageModelDto, UsageReportDto, UsageTotalsDto, + AgentLimitsDto, AgentUsageDto, LimitWindowDto, UsageDayDto, + UsageLimitsReportDto, UsageModelDto, UsageReportDto, UsageTotalsDto, }; use aghub_api::dto::{ agents::{ @@ -256,6 +257,10 @@ fn main() -> Result<(), Box> { export_type::(&cfg)?; export_type::(&cfg)?; + export_type::(&cfg)?; + export_type::(&cfg)?; + export_type::(&cfg)?; + write_index_file(&out_dir)?; if disallowed_dir.exists() { diff --git a/crates/api/src/dto/usage.rs b/crates/api/src/dto/usage.rs index cdaa273b..995dabf2 100644 --- a/crates/api/src/dto/usage.rs +++ b/crates/api/src/dto/usage.rs @@ -70,3 +70,39 @@ pub struct UsageTotalsDto { pub total_tokens: u64, pub cost_usd: Option, } + +/// Remaining-quota report across agents, returned by `GET /api/v1/usage/limits`. +/// +/// Unlike [`UsageReportDto`] (consumed tokens from local ccusage data), this +/// queries each vendor's private OAuth usage endpoint for how much of the +/// current rate-limit window is left. Auth tokens are read from each agent's +/// local credential store. +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct UsageLimitsReportDto { + pub agents: Vec, + pub generated_at: String, + /// Non-fatal notes (e.g. an agent is not logged in, or its endpoint failed). + pub warnings: Vec, +} + +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct AgentLimitsDto { + /// "claude" | "codex" + pub agent: String, + pub windows: Vec, +} + +/// One rate-limit window for an agent. +#[derive(Debug, Serialize, TS)] +#[ts(export)] +pub struct LimitWindowDto { + /// "5h" | "weekly" | "weekly_opus" | "weekly_sonnet" + pub kind: String, + /// Percent of the window consumed, 0-100 (Codex's `percent_left` is + /// converted to `100 - percent_left` here so all windows share a meaning). + pub utilization_pct: f64, + /// ISO-8601 reset time, when the endpoint reports one. + pub resets_at: Option, +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index f1edd16c..c3bb79b4 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -228,6 +228,7 @@ fn build_rocket( routes::plugins::prune_plugins, routes::plugins::validate_plugin, routes::usage::usage_summary, + routes::usage::usage_limits, ], ) .register( diff --git a/crates/api/src/routes/usage.rs b/crates/api/src/routes/usage.rs index ed6aa635..52d3294c 100644 --- a/crates/api/src/routes/usage.rs +++ b/crates/api/src/routes/usage.rs @@ -7,6 +7,7 @@ use std::collections::HashMap; use std::ffi::{OsStr, OsString}; +use std::path::PathBuf; use std::time::Duration; use rocket::serde::json::Json; @@ -14,12 +15,14 @@ use rocket::State; use serde::Deserialize; use crate::dto::usage::{ - AgentUsageDto, UsageDayDto, UsageModelDto, UsageReportDto, UsageTotalsDto, + AgentLimitsDto, AgentUsageDto, LimitWindowDto, UsageDayDto, + UsageLimitsReportDto, UsageModelDto, UsageReportDto, UsageTotalsDto, }; use crate::error::ApiError; use crate::state::UsageState; const CCUSAGE_TIMEOUT: Duration = Duration::from_secs(30); +const LIMITS_TIMEOUT: Duration = Duration::from_secs(15); /// Locate the ccusage binary. Preference order: the sidecar path injected by /// the desktop shell (`UsageState`), then the `AGHUB_CCUSAGE_BIN` env var (dev), @@ -338,3 +341,253 @@ pub async fn usage_summary( warnings, }) } + +// ---- remaining-quota (limits) ---------------------------------------------- +// +// Local credential stores hold the OAuth token each agent already uses; we +// reuse it to call the vendor's private usage endpoint. No new login flow. + +fn home_dir() -> Result { + dirs::home_dir().ok_or_else(|| "cannot resolve home directory".to_string()) +} + +/// Claude Code's OAuth access token. macOS keeps it in the login keychain +/// (service `Claude Code-credentials`); other platforms use the JSON file. +fn claude_access_token() -> Result { + #[derive(Deserialize)] + struct CredFile { + #[serde(rename = "claudeAiOauth")] + oauth: OauthBlock, + } + #[derive(Deserialize)] + struct OauthBlock { + #[serde(rename = "accessToken")] + access_token: String, + } + let parse = |json: &str| -> Result { + serde_json::from_str::(json) + .map(|c| c.oauth.access_token) + .map_err(|e| format!("parse claude credentials: {e}")) + }; + + #[cfg(target_os = "macos")] + { + let user = + std::env::var("USER").map_err(|_| "USER not set".to_string())?; + let entry = keyring::Entry::new("Claude Code-credentials", &user) + .map_err(|e| e.to_string())?; + match entry.get_password() { + Ok(json) => return parse(&json), + // Not in keychain: fall through to the file (some setups use it). + Err(keyring::Error::NoEntry) => {} + Err(e) => return Err(e.to_string()), + } + } + + 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) +} + +/// Codex's OAuth token plus the ChatGPT account id its usage endpoint needs. +fn codex_auth() -> Result<(String, Option), String> { + #[derive(Deserialize)] + struct AuthFile { + tokens: Tokens, + } + #[derive(Deserialize)] + struct Tokens { + access_token: String, + #[serde(default)] + account_id: Option, + } + let path = home_dir()?.join(".codex/auth.json"); + let json = std::fs::read_to_string(&path) + .map_err(|e| format!("read {}: {e}", path.display()))?; + serde_json::from_str::(&json) + .map(|a| (a.tokens.access_token, a.tokens.account_id)) + .map_err(|e| format!("parse codex auth: {e}")) +} + +// ---- Anthropic `GET /api/oauth/usage` shape -------------------------------- + +#[derive(Deserialize)] +struct ClaudeOauthUsage { + #[serde(default)] + five_hour: Option, + #[serde(default)] + seven_day: Option, + #[serde(default)] + seven_day_opus: Option, + #[serde(default)] + seven_day_sonnet: Option, +} + +#[derive(Deserialize)] +struct ClaudeWindow { + utilization: f64, + #[serde(default)] + resets_at: Option, +} + +async fn fetch_claude_limits() -> Result { + let token = claude_access_token()?; + let client = reqwest::Client::builder() + .timeout(LIMITS_TIMEOUT) + .build() + .map_err(|e| e.to_string())?; + let resp = client + .get("https://api.anthropic.com/api/oauth/usage") + .bearer_auth(token) + .header("anthropic-beta", "oauth-2025-04-20") + .send() + .await + .map_err(|e| format!("claude usage request: {e}"))?; + if !resp.status().is_success() { + return Err(format!( + "claude usage endpoint returned {}", + resp.status() + )); + } + let usage: ClaudeOauthUsage = resp + .json() + .await + .map_err(|e| format!("parse claude usage: {e}"))?; + + let windows = [ + ("5h", usage.five_hour), + ("weekly", usage.seven_day), + ("weekly_opus", usage.seven_day_opus), + ("weekly_sonnet", usage.seven_day_sonnet), + ] + .into_iter() + .filter_map(|(kind, w)| { + w.map(|w| LimitWindowDto { + kind: kind.to_string(), + utilization_pct: w.utilization, + resets_at: w.resets_at, + }) + }) + .collect(); + + Ok(AgentLimitsDto { + agent: "claude".to_string(), + windows, + }) +} + +/// Codex's usage shape is undocumented and field names vary across versions, so +/// we extract defensively: `used_percent` directly, or `percent_left` inverted; +/// reset as an ISO string, or seconds-from-now, or epoch millis. +fn codex_window( + value: &serde_json::Value, + kind: &str, +) -> Option { + let obj = value.as_object()?; + let utilization_pct = obj + .get("used_percent") + .and_then(serde_json::Value::as_f64) + .or_else(|| { + obj.get("percent_left") + .and_then(serde_json::Value::as_f64) + .map(|left| 100.0 - left) + })?; + let resets_at = obj + .get("resets_at") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .or_else(|| { + obj.get("resets_in_seconds") + .and_then(serde_json::Value::as_i64) + .map(|secs| { + (chrono::Utc::now() + chrono::Duration::seconds(secs)) + .to_rfc3339() + }) + }) + .or_else(|| { + obj.get("reset_time_ms") + .and_then(serde_json::Value::as_i64) + .and_then(chrono::DateTime::from_timestamp_millis) + .map(|dt| dt.to_rfc3339()) + }); + Some(LimitWindowDto { + kind: kind.to_string(), + utilization_pct, + resets_at, + }) +} + +async fn fetch_codex_limits() -> Result { + let (token, account_id) = codex_auth()?; + let client = reqwest::Client::builder() + .timeout(LIMITS_TIMEOUT) + .build() + .map_err(|e| e.to_string())?; + let mut req = client + .get("https://chatgpt.com/backend-api/wham/usage") + .bearer_auth(token); + if let Some(id) = account_id { + req = req.header("ChatGPT-Account-Id", id); + } + let resp = req + .send() + .await + .map_err(|e| format!("codex usage request: {e}"))?; + if !resp.status().is_success() { + return Err(format!("codex usage endpoint returned {}", resp.status())); + } + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("parse codex usage: {e}"))?; + + // Codex exposes a `primary` (5h) and `secondary` (weekly) window, possibly + // nested under `rate_limits`. + let root = body.get("rate_limits").unwrap_or(&body); + let windows: Vec = + [("primary", "5h"), ("secondary", "weekly")] + .into_iter() + .filter_map(|(key, kind)| { + root.get(key).and_then(|v| codex_window(v, kind)) + }) + .collect(); + if windows.is_empty() { + return Err( + "codex usage response had no recognizable rate-limit windows" + .to_string(), + ); + } + + Ok(AgentLimitsDto { + agent: "codex".to_string(), + windows, + }) +} + +/// `GET /api/v1/usage/limits` — remaining rate-limit quota for Claude and Codex. +/// +/// Degrades like [`usage_summary`]: a not-logged-in or failing agent becomes a +/// `warnings` entry instead of failing the whole request. +#[get("/usage/limits")] +pub async fn usage_limits() -> Json { + let (claude_res, codex_res) = + tokio::join!(fetch_claude_limits(), fetch_codex_limits()); + + let mut agents = Vec::new(); + let mut warnings = Vec::new(); + match claude_res { + Ok(agent) => agents.push(agent), + Err(e) => warnings.push(format!("claude limits unavailable: {e}")), + } + match codex_res { + Ok(agent) => agents.push(agent), + Err(e) => warnings.push(format!("codex limits unavailable: {e}")), + } + + Json(UsageLimitsReportDto { + agents, + generated_at: chrono::Utc::now().to_rfc3339(), + warnings, + }) +} diff --git a/crates/desktop/src/generated/dto/AgentLimitsDto.ts b/crates/desktop/src/generated/dto/AgentLimitsDto.ts new file mode 100644 index 00000000..1c4ce325 --- /dev/null +++ b/crates/desktop/src/generated/dto/AgentLimitsDto.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LimitWindowDto } from "./LimitWindowDto"; + +export type AgentLimitsDto = { + /** + * "claude" | "codex" + */ + agent: string; + windows: Array; +}; diff --git a/crates/desktop/src/generated/dto/LimitWindowDto.ts b/crates/desktop/src/generated/dto/LimitWindowDto.ts new file mode 100644 index 00000000..9bb19b57 --- /dev/null +++ b/crates/desktop/src/generated/dto/LimitWindowDto.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One rate-limit window for an agent. + */ +export type LimitWindowDto = { + /** + * "5h" | "weekly" | "weekly_opus" | "weekly_sonnet" + */ + kind: string; + /** + * Percent of the window consumed, 0-100 (Codex's `percent_left` is + * converted to `100 - percent_left` here so all windows share a meaning). + */ + utilization_pct: number; + /** + * ISO-8601 reset time, when the endpoint reports one. + */ + resets_at: string | null; +}; diff --git a/crates/desktop/src/generated/dto/UsageLimitsReportDto.ts b/crates/desktop/src/generated/dto/UsageLimitsReportDto.ts new file mode 100644 index 00000000..392828f3 --- /dev/null +++ b/crates/desktop/src/generated/dto/UsageLimitsReportDto.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgentLimitsDto } from "./AgentLimitsDto"; + +/** + * Remaining-quota report across agents, returned by `GET /api/v1/usage/limits`. + * + * Unlike [`UsageReportDto`] (consumed tokens from local ccusage data), this + * queries each vendor's private OAuth usage endpoint for how much of the + * current rate-limit window is left. Auth tokens are read from each agent's + * local credential store. + */ +export type UsageLimitsReportDto = { + agents: Array; + generated_at: string; + /** + * Non-fatal notes (e.g. an agent is not logged in, or its endpoint failed). + */ + warnings: Array; +}; diff --git a/crates/desktop/src/generated/dto/index.ts b/crates/desktop/src/generated/dto/index.ts index 238f0a94..f1f51576 100644 --- a/crates/desktop/src/generated/dto/index.ts +++ b/crates/desktop/src/generated/dto/index.ts @@ -1,5 +1,6 @@ export type { AgentAvailabilityDto } from "./AgentAvailabilityDto"; export type { AgentInfo } from "./AgentInfo"; +export type { AgentLimitsDto } from "./AgentLimitsDto"; export type { AgentProviderCredentialDto } from "./AgentProviderCredentialDto"; export type { AgentProviderMatchedInferenceProviderResponse } from "./AgentProviderMatchedInferenceProviderResponse"; export type { AgentProviderModelResponse } from "./AgentProviderModelResponse"; @@ -73,6 +74,7 @@ export type { InferenceProviderResponse } from "./InferenceProviderResponse"; export type { InstallScopeDto } from "./InstallScopeDto"; export type { InstallSkillRequest } from "./InstallSkillRequest"; export type { InstallSkillResponse } from "./InstallSkillResponse"; +export type { LimitWindowDto } from "./LimitWindowDto"; export type { LocalSkillLockEntryResponse } from "./LocalSkillLockEntryResponse"; export type { MarketSkill } from "./MarketSkill"; export type { McpCapabilitiesDto } from "./McpCapabilitiesDto"; @@ -111,6 +113,7 @@ export type { UpdateMcpRequest } from "./UpdateMcpRequest"; export type { UpdateSkillRequest } from "./UpdateSkillRequest"; export type { UpdateSubAgentRequest } from "./UpdateSubAgentRequest"; export type { UsageDayDto } from "./UsageDayDto"; +export type { UsageLimitsReportDto } from "./UsageLimitsReportDto"; export type { UsageModelDto } from "./UsageModelDto"; export type { UsageReportDto } from "./UsageReportDto"; export type { UsageTotalsDto } from "./UsageTotalsDto"; From faf224560fe3ab27d9f97d15c442cbd2d56a2d53 Mon Sep 17 00:00:00 2001 From: Flacier Date: Wed, 3 Jun 2026 11:33:03 +0800 Subject: [PATCH 06/13] refactor(usage): extract reporting logic into aghub-usage crate 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 --- .gitignore | 1 + Cargo.lock | 16 + Cargo.toml | 1 + crates/api/Cargo.toml | 1 + crates/api/src/bin/export-dto.rs | 8 +- crates/api/src/dto/mod.rs | 1 - crates/api/src/routes/usage.rs | 580 +-------------- .../src/generated/dto/UsageReportDto.ts | 2 +- crates/usage/Cargo.toml | 24 + .../src/dto/usage.rs => usage/src/dto.rs} | 2 +- crates/usage/src/lib.rs | 701 ++++++++++++++++++ 11 files changed, 757 insertions(+), 580 deletions(-) create mode 100644 crates/usage/Cargo.toml rename crates/{api/src/dto/usage.rs => usage/src/dto.rs} (98%) create mode 100644 crates/usage/src/lib.rs diff --git a/.gitignore b/.gitignore index acb9eb8e..65c1feb7 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ build/ # ts-rs generated TypeScript bindings (build artifact) crates/api/bindings/ +crates/usage/bindings/ # ccusage sidecar binaries — fetched at build time by scripts/fetch-ccusage.mjs crates/desktop/src-tauri/binaries/ diff --git a/Cargo.lock b/Cargo.lock index 021df60f..d6057552 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,6 +74,7 @@ dependencies = [ "aghub-core", "aghub-git", "aghub-inference", + "aghub-usage", "chrono", "dirs", "keyring", @@ -123,6 +124,7 @@ dependencies = [ "aghub-agents", "aghub-cc-plugins", "aghub-core", + "aghub-usage", "anyhow", "assert_cmd", "clap", @@ -201,6 +203,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "aghub-usage" +version = "1.1.1" +dependencies = [ + "chrono", + "dirs", + "keyring", + "reqwest", + "serde", + "serde_json", + "tokio", + "ts-rs", +] + [[package]] name = "ahash" version = "0.7.8" diff --git a/Cargo.toml b/Cargo.toml index f2249095..5b411744 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/markdown", "crates/inference", "crates/cc-plugins", + "crates/usage", "crates/desktop/src-tauri", ] resolver = "2" diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 7c5bf880..bdd24a08 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -20,6 +20,7 @@ aghub-core = { path = "../core" } aghub-git = { path = "../git" } aghub-inference = { path = "../inference" } aghub-cc-plugins = { path = "../cc-plugins" } +aghub-usage = { path = "../usage" } skill = { path = "../skill" } skills-sh = { path = "../skills-sh" } rocket = { version = "0.5", features = [ "json" ] } diff --git a/crates/api/src/bin/export-dto.rs b/crates/api/src/bin/export-dto.rs index 5f04cd17..1b43b21a 100644 --- a/crates/api/src/bin/export-dto.rs +++ b/crates/api/src/bin/export-dto.rs @@ -4,10 +4,6 @@ use std::{ path::{Path, PathBuf}, }; -use aghub_api::dto::usage::{ - AgentLimitsDto, AgentUsageDto, LimitWindowDto, UsageDayDto, - UsageLimitsReportDto, UsageModelDto, UsageReportDto, UsageTotalsDto, -}; use aghub_api::dto::{ agents::{ AgentAvailabilityDto, AgentInfo, CapabilitiesDto, McpCapabilitiesDto, @@ -74,6 +70,10 @@ use aghub_api::dto::{ TransferRequest, }, }; +use aghub_usage::{ + AgentLimitsDto, AgentUsageDto, LimitWindowDto, UsageDayDto, + UsageLimitsReportDto, UsageModelDto, UsageReportDto, UsageTotalsDto, +}; use ts_rs::{Config, TS}; fn workspace_root() -> PathBuf { diff --git a/crates/api/src/dto/mod.rs b/crates/api/src/dto/mod.rs index 8d4c5322..257d5f0d 100644 --- a/crates/api/src/dto/mod.rs +++ b/crates/api/src/dto/mod.rs @@ -9,4 +9,3 @@ pub mod plugin; pub mod skill; pub mod sub_agent; pub mod transfer; -pub mod usage; diff --git a/crates/api/src/routes/usage.rs b/crates/api/src/routes/usage.rs index 52d3294c..4c19e276 100644 --- a/crates/api/src/routes/usage.rs +++ b/crates/api/src/routes/usage.rs @@ -1,292 +1,14 @@ -//! Usage monitoring: shells out to the bundled `ccusage` binary and normalizes -//! its per-agent `--json` output into the unified [`UsageReportDto`]. -//! -//! ccusage is reused as-is (it owns parsing, dedup, pricing, format tracking); -//! this module is only the adapter layer. Claude and Codex emit different JSON -//! shapes, so each has its own deserialization struct and mapping function. - -use std::collections::HashMap; -use std::ffi::{OsStr, OsString}; -use std::path::PathBuf; -use std::time::Duration; +//! Usage routes: thin handlers over the `aghub-usage` crate, which owns the +//! ccusage shell-out, normalization, and vendor limit-endpoint logic. use rocket::serde::json::Json; use rocket::State; -use serde::Deserialize; - -use crate::dto::usage::{ - AgentLimitsDto, AgentUsageDto, LimitWindowDto, UsageDayDto, - UsageLimitsReportDto, UsageModelDto, UsageReportDto, UsageTotalsDto, -}; -use crate::error::ApiError; -use crate::state::UsageState; - -const CCUSAGE_TIMEOUT: Duration = Duration::from_secs(30); -const LIMITS_TIMEOUT: Duration = Duration::from_secs(15); - -/// Locate the ccusage binary. Preference order: the sidecar path injected by -/// the desktop shell (`UsageState`), then the `AGHUB_CCUSAGE_BIN` env var (dev), -/// then `ccusage` on `PATH`. -fn ccusage_bin(state: &UsageState) -> OsString { - if let Some(path) = &state.ccusage_bin { - return path.clone().into_os_string(); - } - std::env::var_os("AGHUB_CCUSAGE_BIN") - .unwrap_or_else(|| OsString::from("ccusage")) -} - -async fn run_ccusage( - bin: &OsStr, - args: Vec, -) -> Result, ApiError> { - 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() - )) - })?; - if !output.status.success() { - return Err(ApiError::internal(format!( - "ccusage {:?} exited with {}: {}", - args, - output.status, - String::from_utf8_lossy(&output.stderr) - ))); - } - Ok(output.stdout) -} - -/// `ccusage --version` → e.g. "ccusage 20.0.6"; "unknown" if it can't be read. -async fn ccusage_version(bin: &OsStr) -> String { - run_ccusage(bin, vec!["--version".to_string()]) - .await - .ok() - .and_then(|out| String::from_utf8(out).ok()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "unknown".to_string()) -} - -// ---- ccusage `claude daily --json` shape ----------------------------------- - -#[derive(Deserialize)] -struct CcClaudeReport { - daily: Vec, - totals: CcClaudeTotals, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CcClaudeDay { - date: String, - input_tokens: u64, - output_tokens: u64, - cache_creation_tokens: u64, - cache_read_tokens: u64, - total_tokens: u64, - total_cost: f64, - #[serde(default)] - model_breakdowns: Vec, -} - -#[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, -} -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CcClaudeTotals { - input_tokens: u64, - output_tokens: u64, - cache_creation_tokens: u64, - cache_read_tokens: u64, - total_tokens: u64, - total_cost: f64, -} - -// ---- ccusage `codex daily --json` shape ------------------------------------ - -#[derive(Deserialize)] -struct CcCodexReport { - daily: Vec, - totals: CcCodexTotals, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CcCodexDay { - date: String, - input_tokens: u64, - cached_input_tokens: u64, - output_tokens: u64, - reasoning_output_tokens: u64, - total_tokens: u64, - #[serde(rename = "costUSD")] - cost_usd: f64, - #[serde(default)] - models: HashMap, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CcCodexModel { - input_tokens: u64, - cached_input_tokens: u64, - output_tokens: u64, - reasoning_output_tokens: u64, - total_tokens: u64, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CcCodexTotals { - input_tokens: u64, - cached_input_tokens: u64, - output_tokens: u64, - reasoning_output_tokens: u64, - total_tokens: u64, - #[serde(rename = "costUSD")] - cost_usd: f64, -} - -// ---- normalization --------------------------------------------------------- -// -// Decisions baked in here (worth a review): -// * Claude has no reasoning tokens -> reasoning_tokens = 0. -// * Codex has no cache-creation -> cache_creation_tokens = 0, and its -// `cachedInputTokens` maps onto the unified `cache_read_tokens`. -// * Codex's per-model map carries no cost, so per-model cost_usd = None -// (only the day/total cost is known); Claude's per-model cost is Some. -// * Claude's per-model breakdown has no totalTokens field, so we sum it. - -fn claude_to_agent(report: CcClaudeReport) -> AgentUsageDto { - let days = report - .daily - .into_iter() - .map(|d| UsageDayDto { - date: d.date, - input_tokens: d.input_tokens, - output_tokens: d.output_tokens, - cache_creation_tokens: d.cache_creation_tokens, - cache_read_tokens: d.cache_read_tokens, - reasoning_tokens: 0, - total_tokens: d.total_tokens, - cost_usd: Some(d.total_cost), - models: d - .model_breakdowns - .into_iter() - .map(|m| UsageModelDto { - total_tokens: m.input_tokens - + m.output_tokens + m.cache_creation_tokens - + m.cache_read_tokens, - model: m.model_name, - input_tokens: m.input_tokens, - output_tokens: m.output_tokens, - cache_creation_tokens: m.cache_creation_tokens, - cache_read_tokens: m.cache_read_tokens, - reasoning_tokens: 0, - cost_usd: Some(m.cost), - }) - .collect(), - }) - .collect(); - - AgentUsageDto { - agent: "claude".to_string(), - days, - totals: UsageTotalsDto { - input_tokens: report.totals.input_tokens, - output_tokens: report.totals.output_tokens, - cache_creation_tokens: report.totals.cache_creation_tokens, - cache_read_tokens: report.totals.cache_read_tokens, - reasoning_tokens: 0, - total_tokens: report.totals.total_tokens, - cost_usd: Some(report.totals.total_cost), - }, - } -} - -fn codex_to_agent(report: CcCodexReport) -> AgentUsageDto { - let days = report - .daily - .into_iter() - .map(|d| UsageDayDto { - date: d.date, - input_tokens: d.input_tokens, - output_tokens: d.output_tokens, - cache_creation_tokens: 0, - cache_read_tokens: d.cached_input_tokens, - reasoning_tokens: d.reasoning_output_tokens, - total_tokens: d.total_tokens, - cost_usd: Some(d.cost_usd), - models: d - .models - .into_iter() - .map(|(name, m)| UsageModelDto { - model: name, - input_tokens: m.input_tokens, - output_tokens: m.output_tokens, - cache_creation_tokens: 0, - cache_read_tokens: m.cached_input_tokens, - reasoning_tokens: m.reasoning_output_tokens, - total_tokens: m.total_tokens, - cost_usd: None, - }) - .collect(), - }) - .collect(); - - AgentUsageDto { - agent: "codex".to_string(), - days, - totals: UsageTotalsDto { - input_tokens: report.totals.input_tokens, - output_tokens: report.totals.output_tokens, - cache_creation_tokens: 0, - cache_read_tokens: report.totals.cached_input_tokens, - reasoning_tokens: report.totals.reasoning_output_tokens, - total_tokens: report.totals.total_tokens, - cost_usd: Some(report.totals.cost_usd), - }, - } -} +use aghub_usage::{UsageLimitsReportDto, UsageReportDto}; -async fn fetch_claude_usage( - bin: &OsStr, - args: Vec, -) -> Result { - let raw = run_ccusage(bin, args).await.map_err(|e| e.body.error)?; - let report: CcClaudeReport = serde_json::from_slice(&raw) - .map_err(|e| format!("parse claude usage json: {e}"))?; - Ok(claude_to_agent(report)) -} - -async fn fetch_codex_usage( - bin: &OsStr, - args: Vec, -) -> Result { - let raw = run_ccusage(bin, args).await.map_err(|e| e.body.error)?; - let report: CcCodexReport = serde_json::from_slice(&raw) - .map_err(|e| format!("parse codex usage json: {e}"))?; - Ok(codex_to_agent(report)) -} +use crate::state::UsageState; /// `GET /api/v1/usage/summary` — daily token/cost usage for Claude and Codex. -/// -/// Degrades gracefully: if one agent's ccusage call fails (not installed, no -/// data, malformed output) it is reported in `warnings` instead of failing the -/// whole request, so the home page can still render whatever is available. #[get("/usage/summary?&&")] pub async fn usage_summary( usage: &State, @@ -294,300 +16,12 @@ pub async fn usage_summary( until: Option, timezone: Option, ) -> Json { - let bin = ccusage_bin(usage); - let build_args = |agent: &str| -> Vec { - let mut args = vec![ - agent.to_string(), - "daily".to_string(), - "--json".to_string(), - "--offline".to_string(), - ]; - if let Some(s) = &since { - args.push("--since".to_string()); - args.push(s.clone()); - } - if let Some(u) = &until { - args.push("--until".to_string()); - args.push(u.clone()); - } - if let Some(tz) = &timezone { - args.push("--timezone".to_string()); - args.push(tz.clone()); - } - args - }; - - let (version, claude_res, codex_res) = tokio::join!( - ccusage_version(&bin), - fetch_claude_usage(&bin, build_args("claude")), - fetch_codex_usage(&bin, build_args("codex")), - ); - - let mut agents = Vec::new(); - let mut warnings = Vec::new(); - match claude_res { - Ok(agent) => agents.push(agent), - Err(e) => warnings.push(format!("claude usage unavailable: {e}")), - } - match codex_res { - Ok(agent) => agents.push(agent), - Err(e) => warnings.push(format!("codex usage unavailable: {e}")), - } - - Json(UsageReportDto { - agents, - generated_at: chrono::Utc::now().to_rfc3339(), - ccusage_version: version, - warnings, - }) -} - -// ---- remaining-quota (limits) ---------------------------------------------- -// -// Local credential stores hold the OAuth token each agent already uses; we -// reuse it to call the vendor's private usage endpoint. No new login flow. - -fn home_dir() -> Result { - dirs::home_dir().ok_or_else(|| "cannot resolve home directory".to_string()) -} - -/// Claude Code's OAuth access token. macOS keeps it in the login keychain -/// (service `Claude Code-credentials`); other platforms use the JSON file. -fn claude_access_token() -> Result { - #[derive(Deserialize)] - struct CredFile { - #[serde(rename = "claudeAiOauth")] - oauth: OauthBlock, - } - #[derive(Deserialize)] - struct OauthBlock { - #[serde(rename = "accessToken")] - access_token: String, - } - let parse = |json: &str| -> Result { - serde_json::from_str::(json) - .map(|c| c.oauth.access_token) - .map_err(|e| format!("parse claude credentials: {e}")) - }; - - #[cfg(target_os = "macos")] - { - let user = - std::env::var("USER").map_err(|_| "USER not set".to_string())?; - let entry = keyring::Entry::new("Claude Code-credentials", &user) - .map_err(|e| e.to_string())?; - match entry.get_password() { - Ok(json) => return parse(&json), - // Not in keychain: fall through to the file (some setups use it). - Err(keyring::Error::NoEntry) => {} - Err(e) => return Err(e.to_string()), - } - } - - 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) -} - -/// Codex's OAuth token plus the ChatGPT account id its usage endpoint needs. -fn codex_auth() -> Result<(String, Option), String> { - #[derive(Deserialize)] - struct AuthFile { - tokens: Tokens, - } - #[derive(Deserialize)] - struct Tokens { - access_token: String, - #[serde(default)] - account_id: Option, - } - let path = home_dir()?.join(".codex/auth.json"); - let json = std::fs::read_to_string(&path) - .map_err(|e| format!("read {}: {e}", path.display()))?; - serde_json::from_str::(&json) - .map(|a| (a.tokens.access_token, a.tokens.account_id)) - .map_err(|e| format!("parse codex auth: {e}")) -} - -// ---- Anthropic `GET /api/oauth/usage` shape -------------------------------- - -#[derive(Deserialize)] -struct ClaudeOauthUsage { - #[serde(default)] - five_hour: Option, - #[serde(default)] - seven_day: Option, - #[serde(default)] - seven_day_opus: Option, - #[serde(default)] - seven_day_sonnet: Option, -} - -#[derive(Deserialize)] -struct ClaudeWindow { - utilization: f64, - #[serde(default)] - resets_at: Option, -} - -async fn fetch_claude_limits() -> Result { - let token = claude_access_token()?; - let client = reqwest::Client::builder() - .timeout(LIMITS_TIMEOUT) - .build() - .map_err(|e| e.to_string())?; - let resp = client - .get("https://api.anthropic.com/api/oauth/usage") - .bearer_auth(token) - .header("anthropic-beta", "oauth-2025-04-20") - .send() - .await - .map_err(|e| format!("claude usage request: {e}"))?; - if !resp.status().is_success() { - return Err(format!( - "claude usage endpoint returned {}", - resp.status() - )); - } - let usage: ClaudeOauthUsage = resp - .json() - .await - .map_err(|e| format!("parse claude usage: {e}"))?; - - let windows = [ - ("5h", usage.five_hour), - ("weekly", usage.seven_day), - ("weekly_opus", usage.seven_day_opus), - ("weekly_sonnet", usage.seven_day_sonnet), - ] - .into_iter() - .filter_map(|(kind, w)| { - w.map(|w| LimitWindowDto { - kind: kind.to_string(), - utilization_pct: w.utilization, - resets_at: w.resets_at, - }) - }) - .collect(); - - Ok(AgentLimitsDto { - agent: "claude".to_string(), - windows, - }) -} - -/// Codex's usage shape is undocumented and field names vary across versions, so -/// we extract defensively: `used_percent` directly, or `percent_left` inverted; -/// reset as an ISO string, or seconds-from-now, or epoch millis. -fn codex_window( - value: &serde_json::Value, - kind: &str, -) -> Option { - let obj = value.as_object()?; - let utilization_pct = obj - .get("used_percent") - .and_then(serde_json::Value::as_f64) - .or_else(|| { - obj.get("percent_left") - .and_then(serde_json::Value::as_f64) - .map(|left| 100.0 - left) - })?; - let resets_at = obj - .get("resets_at") - .and_then(serde_json::Value::as_str) - .map(str::to_string) - .or_else(|| { - obj.get("resets_in_seconds") - .and_then(serde_json::Value::as_i64) - .map(|secs| { - (chrono::Utc::now() + chrono::Duration::seconds(secs)) - .to_rfc3339() - }) - }) - .or_else(|| { - obj.get("reset_time_ms") - .and_then(serde_json::Value::as_i64) - .and_then(chrono::DateTime::from_timestamp_millis) - .map(|dt| dt.to_rfc3339()) - }); - Some(LimitWindowDto { - kind: kind.to_string(), - utilization_pct, - resets_at, - }) -} - -async fn fetch_codex_limits() -> Result { - let (token, account_id) = codex_auth()?; - let client = reqwest::Client::builder() - .timeout(LIMITS_TIMEOUT) - .build() - .map_err(|e| e.to_string())?; - let mut req = client - .get("https://chatgpt.com/backend-api/wham/usage") - .bearer_auth(token); - if let Some(id) = account_id { - req = req.header("ChatGPT-Account-Id", id); - } - let resp = req - .send() - .await - .map_err(|e| format!("codex usage request: {e}"))?; - if !resp.status().is_success() { - return Err(format!("codex usage endpoint returned {}", resp.status())); - } - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("parse codex usage: {e}"))?; - - // Codex exposes a `primary` (5h) and `secondary` (weekly) window, possibly - // nested under `rate_limits`. - let root = body.get("rate_limits").unwrap_or(&body); - let windows: Vec = - [("primary", "5h"), ("secondary", "weekly")] - .into_iter() - .filter_map(|(key, kind)| { - root.get(key).and_then(|v| codex_window(v, kind)) - }) - .collect(); - if windows.is_empty() { - return Err( - "codex usage response had no recognizable rate-limit windows" - .to_string(), - ); - } - - Ok(AgentLimitsDto { - agent: "codex".to_string(), - windows, - }) + let bin = aghub_usage::resolve_ccusage_bin(usage.ccusage_bin.clone()); + Json(aghub_usage::summary(&bin, since, until, timezone).await) } /// `GET /api/v1/usage/limits` — remaining rate-limit quota for Claude and Codex. -/// -/// Degrades like [`usage_summary`]: a not-logged-in or failing agent becomes a -/// `warnings` entry instead of failing the whole request. #[get("/usage/limits")] pub async fn usage_limits() -> Json { - let (claude_res, codex_res) = - tokio::join!(fetch_claude_limits(), fetch_codex_limits()); - - let mut agents = Vec::new(); - let mut warnings = Vec::new(); - match claude_res { - Ok(agent) => agents.push(agent), - Err(e) => warnings.push(format!("claude limits unavailable: {e}")), - } - match codex_res { - Ok(agent) => agents.push(agent), - Err(e) => warnings.push(format!("codex limits unavailable: {e}")), - } - - Json(UsageLimitsReportDto { - agents, - generated_at: chrono::Utc::now().to_rfc3339(), - warnings, - }) + Json(aghub_usage::limits().await) } diff --git a/crates/desktop/src/generated/dto/UsageReportDto.ts b/crates/desktop/src/generated/dto/UsageReportDto.ts index 4aa646c6..7e7e7887 100644 --- a/crates/desktop/src/generated/dto/UsageReportDto.ts +++ b/crates/desktop/src/generated/dto/UsageReportDto.ts @@ -7,7 +7,7 @@ import type { AgentUsageDto } from "./AgentUsageDto"; * ccusage emits a different JSON shape per agent (claude has cache-creation, * codex has reasoning, cost keys differ); this DTO is the normalized shape the * frontend consumes. The mapping from each ccusage shape lives in - * `routes::usage`. + * `claude_to_agent` / `codex_to_agent`. */ export type UsageReportDto = { agents: Array; diff --git a/crates/usage/Cargo.toml b/crates/usage/Cargo.toml new file mode 100644 index 00000000..6f90ac44 --- /dev/null +++ b/crates/usage/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "aghub-usage" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "Usage and rate-limit reporting for AI coding agents" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +ts-rs = { workspace = true } +dirs = { workspace = true } +tokio = { version = "1", features = [ "process", "time" ] } +reqwest = { version = "0.13", features = [ "json" ] } +chrono = "0.4" +keyring = { version = "3", features = [ + "apple-native", + "windows-native", + "linux-native", +] } + +[dev-dependencies] +tokio = { version = "1", features = [ "process", "time", "rt", "macros" ] } diff --git a/crates/api/src/dto/usage.rs b/crates/usage/src/dto.rs similarity index 98% rename from crates/api/src/dto/usage.rs rename to crates/usage/src/dto.rs index 995dabf2..9426ac9e 100644 --- a/crates/api/src/dto/usage.rs +++ b/crates/usage/src/dto.rs @@ -6,7 +6,7 @@ use ts_rs::TS; /// ccusage emits a different JSON shape per agent (claude has cache-creation, /// codex has reasoning, cost keys differ); this DTO is the normalized shape the /// frontend consumes. The mapping from each ccusage shape lives in -/// `routes::usage`. +/// `claude_to_agent` / `codex_to_agent`. #[derive(Debug, Serialize, TS)] #[ts(export)] pub struct UsageReportDto { diff --git a/crates/usage/src/lib.rs b/crates/usage/src/lib.rs new file mode 100644 index 00000000..92b8450c --- /dev/null +++ b/crates/usage/src/lib.rs @@ -0,0 +1,701 @@ +//! Usage and rate-limit reporting for AI coding agents. +//! +//! Two independent data sources: +//! * `summary` shells out to the bundled `ccusage` binary and normalizes its +//! per-agent `--json` output into the unified [`UsageReportDto`]. +//! * `limits` queries each vendor's private OAuth usage endpoint for how much +//! of the current rate-limit window is left, using tokens read from each +//! agent's local credential store. +//! +//! ccusage is reused as-is (it owns parsing, dedup, pricing, format tracking); +//! this crate is only the adapter layer. Claude and Codex emit different JSON +//! shapes, so each has its own deserialization struct and mapping function. + +mod dto; +pub use dto::*; + +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; +use std::path::PathBuf; +use std::time::Duration; + +use serde::Deserialize; + +const CCUSAGE_TIMEOUT: Duration = Duration::from_secs(30); +const LIMITS_TIMEOUT: Duration = Duration::from_secs(15); + +/// Locate the ccusage binary. Preference order: an explicit path injected by the +/// caller (the desktop shell passes the bundled sidecar), then the +/// `AGHUB_CCUSAGE_BIN` env var (dev), then `ccusage` on `PATH`. +pub fn resolve_ccusage_bin(explicit: Option) -> OsString { + if let Some(path) = explicit { + return path.into_os_string(); + } + std::env::var_os("AGHUB_CCUSAGE_BIN") + .unwrap_or_else(|| OsString::from("ccusage")) +} + +async fn run_ccusage( + bin: &OsStr, + args: Vec, +) -> Result, String> { + let run = tokio::process::Command::new(bin).args(&args).output(); + let output = tokio::time::timeout(CCUSAGE_TIMEOUT, run) + .await + .map_err(|_| "ccusage timed out after 30s".to_string())? + .map_err(|e| { + format!("failed to spawn ccusage ({}): {e}", bin.to_string_lossy()) + })?; + if !output.status.success() { + return Err(format!( + "ccusage {:?} exited with {}: {}", + args, + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + Ok(output.stdout) +} + +/// `ccusage --version` → e.g. "ccusage 20.0.6"; "unknown" if it can't be read. +async fn ccusage_version(bin: &OsStr) -> String { + run_ccusage(bin, vec!["--version".to_string()]) + .await + .ok() + .and_then(|out| String::from_utf8(out).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) +} + +// ---- ccusage `claude daily --json` shape ----------------------------------- + +#[derive(Deserialize)] +struct CcClaudeReport { + daily: Vec, + totals: CcClaudeTotals, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CcClaudeDay { + date: String, + input_tokens: u64, + output_tokens: u64, + cache_creation_tokens: u64, + cache_read_tokens: u64, + total_tokens: u64, + total_cost: f64, + #[serde(default)] + model_breakdowns: Vec, +} + +#[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, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CcClaudeTotals { + input_tokens: u64, + output_tokens: u64, + cache_creation_tokens: u64, + cache_read_tokens: u64, + total_tokens: u64, + total_cost: f64, +} + +// ---- ccusage `codex daily --json` shape ------------------------------------ + +#[derive(Deserialize)] +struct CcCodexReport { + daily: Vec, + totals: CcCodexTotals, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CcCodexDay { + date: String, + input_tokens: u64, + cached_input_tokens: u64, + output_tokens: u64, + reasoning_output_tokens: u64, + total_tokens: u64, + #[serde(rename = "costUSD")] + cost_usd: f64, + #[serde(default)] + models: HashMap, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CcCodexModel { + input_tokens: u64, + cached_input_tokens: u64, + output_tokens: u64, + reasoning_output_tokens: u64, + total_tokens: u64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CcCodexTotals { + input_tokens: u64, + cached_input_tokens: u64, + output_tokens: u64, + reasoning_output_tokens: u64, + total_tokens: u64, + #[serde(rename = "costUSD")] + cost_usd: f64, +} + +// ---- normalization --------------------------------------------------------- +// +// Decisions baked in here (worth a review): +// * Claude has no reasoning tokens -> reasoning_tokens = 0. +// * Codex has no cache-creation -> cache_creation_tokens = 0, and its +// `cachedInputTokens` maps onto the unified `cache_read_tokens`. +// * Codex's per-model map carries no cost, so per-model cost_usd = None +// (only the day/total cost is known); Claude's per-model cost is Some. +// * Claude's per-model breakdown has no totalTokens field, so we sum it. + +fn claude_to_agent(report: CcClaudeReport) -> AgentUsageDto { + let days = report + .daily + .into_iter() + .map(|d| UsageDayDto { + date: d.date, + input_tokens: d.input_tokens, + output_tokens: d.output_tokens, + cache_creation_tokens: d.cache_creation_tokens, + cache_read_tokens: d.cache_read_tokens, + reasoning_tokens: 0, + total_tokens: d.total_tokens, + cost_usd: Some(d.total_cost), + models: d + .model_breakdowns + .into_iter() + .map(|m| UsageModelDto { + total_tokens: m.input_tokens + + m.output_tokens + m.cache_creation_tokens + + m.cache_read_tokens, + model: m.model_name, + input_tokens: m.input_tokens, + output_tokens: m.output_tokens, + cache_creation_tokens: m.cache_creation_tokens, + cache_read_tokens: m.cache_read_tokens, + reasoning_tokens: 0, + cost_usd: Some(m.cost), + }) + .collect(), + }) + .collect(); + + AgentUsageDto { + agent: "claude".to_string(), + days, + totals: UsageTotalsDto { + input_tokens: report.totals.input_tokens, + output_tokens: report.totals.output_tokens, + cache_creation_tokens: report.totals.cache_creation_tokens, + cache_read_tokens: report.totals.cache_read_tokens, + reasoning_tokens: 0, + total_tokens: report.totals.total_tokens, + cost_usd: Some(report.totals.total_cost), + }, + } +} + +fn codex_to_agent(report: CcCodexReport) -> AgentUsageDto { + let days = report + .daily + .into_iter() + .map(|d| UsageDayDto { + date: d.date, + input_tokens: d.input_tokens, + output_tokens: d.output_tokens, + cache_creation_tokens: 0, + cache_read_tokens: d.cached_input_tokens, + reasoning_tokens: d.reasoning_output_tokens, + total_tokens: d.total_tokens, + cost_usd: Some(d.cost_usd), + models: d + .models + .into_iter() + .map(|(name, m)| UsageModelDto { + model: name, + input_tokens: m.input_tokens, + output_tokens: m.output_tokens, + cache_creation_tokens: 0, + cache_read_tokens: m.cached_input_tokens, + reasoning_tokens: m.reasoning_output_tokens, + total_tokens: m.total_tokens, + cost_usd: None, + }) + .collect(), + }) + .collect(); + + AgentUsageDto { + agent: "codex".to_string(), + days, + totals: UsageTotalsDto { + input_tokens: report.totals.input_tokens, + output_tokens: report.totals.output_tokens, + cache_creation_tokens: 0, + cache_read_tokens: report.totals.cached_input_tokens, + reasoning_tokens: report.totals.reasoning_output_tokens, + total_tokens: report.totals.total_tokens, + cost_usd: Some(report.totals.cost_usd), + }, + } +} + +async fn fetch_claude_usage( + bin: &OsStr, + args: Vec, +) -> Result { + let raw = run_ccusage(bin, args).await?; + let report: CcClaudeReport = serde_json::from_slice(&raw) + .map_err(|e| format!("parse claude usage json: {e}"))?; + Ok(claude_to_agent(report)) +} + +async fn fetch_codex_usage( + bin: &OsStr, + args: Vec, +) -> Result { + let raw = run_ccusage(bin, args).await?; + let report: CcCodexReport = serde_json::from_slice(&raw) + .map_err(|e| format!("parse codex usage json: {e}"))?; + Ok(codex_to_agent(report)) +} + +/// Daily token/cost usage for Claude and Codex. +/// +/// Degrades gracefully: if one agent's ccusage call fails (not installed, no +/// data, malformed output) it is reported in `warnings` instead of failing the +/// whole request, so the home page can still render whatever is available. +pub async fn summary( + bin: &OsStr, + since: Option, + until: Option, + timezone: Option, +) -> UsageReportDto { + let build_args = |agent: &str| -> Vec { + let mut args = vec![ + agent.to_string(), + "daily".to_string(), + "--json".to_string(), + "--offline".to_string(), + ]; + if let Some(s) = &since { + args.push("--since".to_string()); + args.push(s.clone()); + } + if let Some(u) = &until { + args.push("--until".to_string()); + args.push(u.clone()); + } + if let Some(tz) = &timezone { + args.push("--timezone".to_string()); + args.push(tz.clone()); + } + args + }; + + let (version, claude_res, codex_res) = tokio::join!( + ccusage_version(bin), + fetch_claude_usage(bin, build_args("claude")), + fetch_codex_usage(bin, build_args("codex")), + ); + + let mut agents = Vec::new(); + let mut warnings = Vec::new(); + match claude_res { + Ok(agent) => agents.push(agent), + Err(e) => warnings.push(format!("claude usage unavailable: {e}")), + } + match codex_res { + Ok(agent) => agents.push(agent), + Err(e) => warnings.push(format!("codex usage unavailable: {e}")), + } + + UsageReportDto { + agents, + generated_at: chrono::Utc::now().to_rfc3339(), + ccusage_version: version, + warnings, + } +} + +// ---- remaining-quota (limits) ---------------------------------------------- +// +// Local credential stores hold the OAuth token each agent already uses; we +// reuse it to call the vendor's private usage endpoint. No new login flow. + +fn home_dir() -> Result { + dirs::home_dir().ok_or_else(|| "cannot resolve home directory".to_string()) +} + +/// Claude Code's OAuth access token. macOS keeps it in the login keychain +/// (service `Claude Code-credentials`); other platforms use the JSON file. +fn claude_access_token() -> Result { + #[derive(Deserialize)] + struct CredFile { + #[serde(rename = "claudeAiOauth")] + oauth: OauthBlock, + } + #[derive(Deserialize)] + struct OauthBlock { + #[serde(rename = "accessToken")] + access_token: String, + } + let parse = |json: &str| -> Result { + serde_json::from_str::(json) + .map(|c| c.oauth.access_token) + .map_err(|e| format!("parse claude credentials: {e}")) + }; + + #[cfg(target_os = "macos")] + { + let user = + std::env::var("USER").map_err(|_| "USER not set".to_string())?; + let entry = keyring::Entry::new("Claude Code-credentials", &user) + .map_err(|e| e.to_string())?; + match entry.get_password() { + Ok(json) => return parse(&json), + // Not in keychain: fall through to the file (some setups use it). + Err(keyring::Error::NoEntry) => {} + Err(e) => return Err(e.to_string()), + } + } + + 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) +} + +/// Codex's OAuth token plus the ChatGPT account id its usage endpoint needs. +fn codex_auth() -> Result<(String, Option), String> { + #[derive(Deserialize)] + struct AuthFile { + tokens: Tokens, + } + #[derive(Deserialize)] + struct Tokens { + access_token: String, + #[serde(default)] + account_id: Option, + } + let path = home_dir()?.join(".codex/auth.json"); + let json = std::fs::read_to_string(&path) + .map_err(|e| format!("read {}: {e}", path.display()))?; + serde_json::from_str::(&json) + .map(|a| (a.tokens.access_token, a.tokens.account_id)) + .map_err(|e| format!("parse codex auth: {e}")) +} + +// ---- Anthropic `GET /api/oauth/usage` shape -------------------------------- + +#[derive(Deserialize)] +struct ClaudeOauthUsage { + #[serde(default)] + five_hour: Option, + #[serde(default)] + seven_day: Option, + #[serde(default)] + seven_day_opus: Option, + #[serde(default)] + seven_day_sonnet: Option, +} + +#[derive(Deserialize)] +struct ClaudeWindow { + utilization: f64, + #[serde(default)] + resets_at: Option, +} + +async fn fetch_claude_limits() -> Result { + let token = claude_access_token()?; + let client = reqwest::Client::builder() + .timeout(LIMITS_TIMEOUT) + .build() + .map_err(|e| e.to_string())?; + let resp = client + .get("https://api.anthropic.com/api/oauth/usage") + .bearer_auth(token) + .header("anthropic-beta", "oauth-2025-04-20") + .send() + .await + .map_err(|e| format!("claude usage request: {e}"))?; + if !resp.status().is_success() { + return Err(format!( + "claude usage endpoint returned {}", + resp.status() + )); + } + let usage: ClaudeOauthUsage = resp + .json() + .await + .map_err(|e| format!("parse claude usage: {e}"))?; + + let windows = [ + ("5h", usage.five_hour), + ("weekly", usage.seven_day), + ("weekly_opus", usage.seven_day_opus), + ("weekly_sonnet", usage.seven_day_sonnet), + ] + .into_iter() + .filter_map(|(kind, w)| { + w.map(|w| LimitWindowDto { + kind: kind.to_string(), + utilization_pct: w.utilization, + resets_at: w.resets_at, + }) + }) + .collect(); + + Ok(AgentLimitsDto { + agent: "claude".to_string(), + windows, + }) +} + +/// Codex's usage shape is undocumented and field names vary across versions, so +/// we extract defensively: `used_percent` directly, or `percent_left` inverted; +/// reset as an ISO string, or seconds-from-now, or epoch millis. +fn codex_window( + value: &serde_json::Value, + kind: &str, +) -> Option { + let obj = value.as_object()?; + let utilization_pct = obj + .get("used_percent") + .and_then(serde_json::Value::as_f64) + .or_else(|| { + obj.get("percent_left") + .and_then(serde_json::Value::as_f64) + .map(|left| 100.0 - left) + })?; + let resets_at = obj + .get("resets_at") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .or_else(|| { + obj.get("resets_in_seconds") + .and_then(serde_json::Value::as_i64) + .map(|secs| { + (chrono::Utc::now() + chrono::Duration::seconds(secs)) + .to_rfc3339() + }) + }) + .or_else(|| { + obj.get("reset_time_ms") + .and_then(serde_json::Value::as_i64) + .and_then(chrono::DateTime::from_timestamp_millis) + .map(|dt| dt.to_rfc3339()) + }); + Some(LimitWindowDto { + kind: kind.to_string(), + utilization_pct, + resets_at, + }) +} + +async fn fetch_codex_limits() -> Result { + let (token, account_id) = codex_auth()?; + let client = reqwest::Client::builder() + .timeout(LIMITS_TIMEOUT) + .build() + .map_err(|e| e.to_string())?; + let mut req = client + .get("https://chatgpt.com/backend-api/wham/usage") + .bearer_auth(token); + if let Some(id) = account_id { + req = req.header("ChatGPT-Account-Id", id); + } + let resp = req + .send() + .await + .map_err(|e| format!("codex usage request: {e}"))?; + if !resp.status().is_success() { + return Err(format!("codex usage endpoint returned {}", resp.status())); + } + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("parse codex usage: {e}"))?; + + // Codex exposes a `primary` (5h) and `secondary` (weekly) window, possibly + // nested under `rate_limits`. + let root = body.get("rate_limits").unwrap_or(&body); + let windows: Vec = + [("primary", "5h"), ("secondary", "weekly")] + .into_iter() + .filter_map(|(key, kind)| { + root.get(key).and_then(|v| codex_window(v, kind)) + }) + .collect(); + if windows.is_empty() { + return Err( + "codex usage response had no recognizable rate-limit windows" + .to_string(), + ); + } + + Ok(AgentLimitsDto { + agent: "codex".to_string(), + windows, + }) +} + +/// Remaining rate-limit quota for Claude and Codex. +/// +/// Degrades like [`summary`]: a not-logged-in or failing agent becomes a +/// `warnings` entry instead of failing the whole request. +pub async fn limits() -> UsageLimitsReportDto { + let (claude_res, codex_res) = + tokio::join!(fetch_claude_limits(), fetch_codex_limits()); + + let mut agents = Vec::new(); + let mut warnings = Vec::new(); + match claude_res { + Ok(agent) => agents.push(agent), + Err(e) => warnings.push(format!("claude limits unavailable: {e}")), + } + match codex_res { + Ok(agent) => agents.push(agent), + Err(e) => warnings.push(format!("codex limits unavailable: {e}")), + } + + UsageLimitsReportDto { + agents, + generated_at: chrono::Utc::now().to_rfc3339(), + warnings, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn codex_window_reads_used_percent() { + let w = codex_window(&json!({ "used_percent": 42.5 }), "5h").unwrap(); + assert_eq!(w.kind, "5h"); + assert_eq!(w.utilization_pct, 42.5); + assert_eq!(w.resets_at, None); + } + + #[test] + fn codex_window_inverts_percent_left() { + let w = + codex_window(&json!({ "percent_left": 30.0 }), "weekly").unwrap(); + assert_eq!(w.utilization_pct, 70.0); + } + + #[test] + fn codex_window_resolves_resets_in_seconds() { + let w = codex_window( + &json!({ "used_percent": 10.0, "resets_in_seconds": 3600 }), + "5h", + ) + .unwrap(); + assert!(w.resets_at.is_some()); + } + + #[test] + fn codex_window_none_without_utilization() { + assert!(codex_window(&json!({ "foo": 1 }), "5h").is_none()); + assert!(codex_window(&json!("not an object"), "5h").is_none()); + } + + #[test] + fn claude_normalization_zeroes_reasoning_and_sums_model_tokens() { + let report = CcClaudeReport { + daily: vec![CcClaudeDay { + date: "2026-06-01".to_string(), + input_tokens: 100, + output_tokens: 50, + cache_creation_tokens: 5, + cache_read_tokens: 3, + total_tokens: 158, + total_cost: 1.25, + model_breakdowns: vec![CcClaudeModel { + model_name: "claude-opus-4".to_string(), + input_tokens: 100, + output_tokens: 50, + cache_creation_tokens: 5, + cache_read_tokens: 3, + cost: 1.25, + }], + }], + totals: CcClaudeTotals { + input_tokens: 100, + output_tokens: 50, + cache_creation_tokens: 5, + cache_read_tokens: 3, + total_tokens: 158, + total_cost: 1.25, + }, + }; + let agent = claude_to_agent(report); + assert_eq!(agent.agent, "claude"); + assert_eq!(agent.totals.reasoning_tokens, 0); + let model = &agent.days[0].models[0]; + assert_eq!(model.total_tokens, 158); + assert_eq!(model.cost_usd, Some(1.25)); + } + + #[test] + fn codex_normalization_maps_cached_input_and_drops_model_cost() { + let mut models = HashMap::new(); + models.insert( + "gpt-5".to_string(), + CcCodexModel { + input_tokens: 200, + cached_input_tokens: 40, + output_tokens: 80, + reasoning_output_tokens: 20, + total_tokens: 340, + }, + ); + let report = CcCodexReport { + daily: vec![CcCodexDay { + date: "2026-06-01".to_string(), + input_tokens: 200, + cached_input_tokens: 40, + output_tokens: 80, + reasoning_output_tokens: 20, + total_tokens: 340, + cost_usd: 0.5, + models, + }], + totals: CcCodexTotals { + input_tokens: 200, + cached_input_tokens: 40, + output_tokens: 80, + reasoning_output_tokens: 20, + total_tokens: 340, + cost_usd: 0.5, + }, + }; + let agent = codex_to_agent(report); + assert_eq!(agent.agent, "codex"); + assert_eq!(agent.totals.cache_creation_tokens, 0); + assert_eq!(agent.totals.cache_read_tokens, 40); + assert_eq!(agent.days[0].models[0].cost_usd, None); + } +} From dc4b454b632f6c5ddef916288d7ad228486256dc Mon Sep 17 00:00:00 2001 From: Flacier Date: Wed, 3 Jun 2026 11:33:14 +0800 Subject: [PATCH 07/13] feat(cli): add usage command for token and rate-limit reporting 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. --- crates/cli/Cargo.toml | 1 + crates/cli/src/commands/mod.rs | 1 + crates/cli/src/commands/usage.rs | 54 ++++++++++++++++++++++++++++++++ crates/cli/src/main.rs | 16 +++++++++- 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 crates/cli/src/commands/usage.rs diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 2e6b8b00..34d288c7 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" aghub-core = { path = "../core" } aghub-agents = { path = "../agents" } aghub-cc-plugins = { path = "../cc-plugins" } +aghub-usage = { path = "../usage" } skill = { path = "../skill" } clap = { workspace = true } diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 49189633..a6159d3c 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod enable; pub mod get; pub mod plugin; pub mod update; +pub mod usage; use aghub_core::models::McpTransport; use anyhow::{bail, Result}; diff --git a/crates/cli/src/commands/usage.rs b/crates/cli/src/commands/usage.rs new file mode 100644 index 00000000..9ec6f977 --- /dev/null +++ b/crates/cli/src/commands/usage.rs @@ -0,0 +1,54 @@ +//! `aghub-cli usage ...` subcommands. +//! +//! Thin CLI surface over the `aghub_usage` crate. Both subcommands print the +//! same JSON the desktop app consumes; the CLI does not bundle the ccusage +//! sidecar, so `summary` resolves it from `AGHUB_CCUSAGE_BIN` or `PATH`. + +use anyhow::{Context, Result}; +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum UsageAction { + /// Daily token and cost usage for Claude and Codex from local ccusage data + Summary { + /// Start date, YYYY-MM-DD (passed through to ccusage) + #[arg(long)] + since: Option, + /// End date, YYYY-MM-DD (passed through to ccusage) + #[arg(long)] + until: Option, + /// IANA timezone for day bucketing (passed through to ccusage) + #[arg(long)] + timezone: Option, + }, + /// Remaining rate-limit quota for Claude and Codex from each vendor endpoint + Limits, +} + +pub fn execute(action: UsageAction) -> Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("Failed to build tokio runtime")?; + runtime.block_on(dispatch(action)) +} + +async fn dispatch(action: UsageAction) -> Result<()> { + match action { + UsageAction::Summary { + since, + until, + timezone, + } => { + let bin = aghub_usage::resolve_ccusage_bin(None); + let report = + aghub_usage::summary(&bin, since, until, timezone).await; + println!("{}", serde_json::to_string_pretty(&report)?); + } + UsageAction::Limits => { + let report = aghub_usage::limits().await; + println!("{}", serde_json::to_string_pretty(&report)?); + } + } + Ok(()) +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index cea3a4ad..b8a706dc 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -13,7 +13,7 @@ use aghub_core::{ mod commands; -use commands::{add, delete, disable, enable, get, plugin, update}; +use commands::{add, delete, disable, enable, get, plugin, update, usage}; /// Global verbose flag used by the eprintln_verbose macro static VERBOSE: AtomicBool = AtomicBool::new(false); @@ -205,6 +205,11 @@ enum Commands { #[command(subcommand)] action: plugin::PluginAction, }, + /// Report token usage and rate-limit quota for Claude and Codex + Usage { + #[command(subcommand)] + action: usage::UsageAction, + }, } #[derive(ValueEnum, Clone, Copy, Debug)] @@ -221,6 +226,12 @@ fn main() -> Result<()> { // Set global verbose flag set_verbose(cli.verbose); + // Usage reporting is agent-agnostic: it reads each vendor's own data, so it + // runs before agent/scope resolution and config loading. + if let Commands::Usage { action } = cli.command { + return usage::execute(action); + } + // Handle --agent all: iterate all registered agents if cli.agent == "all" { return handle_all_agents(&cli); @@ -380,6 +391,9 @@ fn main() -> Result<()> { } plugin::execute(action) } + Commands::Usage { .. } => { + unreachable!("usage is dispatched before agent resolution") + } } } From 3951b7b5c462130964123668211f0d8da73a2441 Mon Sep 17 00:00:00 2001 From: Flacier Date: Thu, 4 Jun 2026 14:50:19 +0800 Subject: [PATCH 08/13] fix(usage): tolerate null cost from ccusage output 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 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, so no TS regeneration. --- crates/usage/src/lib.rs | 75 +++++++++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 17 deletions(-) diff --git a/crates/usage/src/lib.rs b/crates/usage/src/lib.rs index 92b8450c..2134ea9a 100644 --- a/crates/usage/src/lib.rs +++ b/crates/usage/src/lib.rs @@ -85,7 +85,8 @@ struct CcClaudeDay { cache_creation_tokens: u64, cache_read_tokens: u64, total_tokens: u64, - total_cost: f64, + #[serde(default)] + total_cost: Option, #[serde(default)] model_breakdowns: Vec, } @@ -98,7 +99,8 @@ struct CcClaudeModel { output_tokens: u64, cache_creation_tokens: u64, cache_read_tokens: u64, - cost: f64, + #[serde(default)] + cost: Option, } #[derive(Deserialize)] @@ -109,7 +111,8 @@ struct CcClaudeTotals { cache_creation_tokens: u64, cache_read_tokens: u64, total_tokens: u64, - total_cost: f64, + #[serde(default)] + total_cost: Option, } // ---- ccusage `codex daily --json` shape ------------------------------------ @@ -129,8 +132,8 @@ struct CcCodexDay { output_tokens: u64, reasoning_output_tokens: u64, total_tokens: u64, - #[serde(rename = "costUSD")] - cost_usd: f64, + #[serde(default, rename = "costUSD")] + cost_usd: Option, #[serde(default)] models: HashMap, } @@ -153,8 +156,8 @@ struct CcCodexTotals { output_tokens: u64, reasoning_output_tokens: u64, total_tokens: u64, - #[serde(rename = "costUSD")] - cost_usd: f64, + #[serde(default, rename = "costUSD")] + cost_usd: Option, } // ---- normalization --------------------------------------------------------- @@ -179,7 +182,7 @@ fn claude_to_agent(report: CcClaudeReport) -> AgentUsageDto { cache_read_tokens: d.cache_read_tokens, reasoning_tokens: 0, total_tokens: d.total_tokens, - cost_usd: Some(d.total_cost), + cost_usd: d.total_cost, models: d .model_breakdowns .into_iter() @@ -193,7 +196,7 @@ fn claude_to_agent(report: CcClaudeReport) -> AgentUsageDto { cache_creation_tokens: m.cache_creation_tokens, cache_read_tokens: m.cache_read_tokens, reasoning_tokens: 0, - cost_usd: Some(m.cost), + cost_usd: m.cost, }) .collect(), }) @@ -209,7 +212,7 @@ fn claude_to_agent(report: CcClaudeReport) -> AgentUsageDto { cache_read_tokens: report.totals.cache_read_tokens, reasoning_tokens: 0, total_tokens: report.totals.total_tokens, - cost_usd: Some(report.totals.total_cost), + cost_usd: report.totals.total_cost, }, } } @@ -226,7 +229,7 @@ fn codex_to_agent(report: CcCodexReport) -> AgentUsageDto { cache_read_tokens: d.cached_input_tokens, reasoning_tokens: d.reasoning_output_tokens, total_tokens: d.total_tokens, - cost_usd: Some(d.cost_usd), + cost_usd: d.cost_usd, models: d .models .into_iter() @@ -254,7 +257,7 @@ fn codex_to_agent(report: CcCodexReport) -> AgentUsageDto { cache_read_tokens: report.totals.cached_input_tokens, reasoning_tokens: report.totals.reasoning_output_tokens, total_tokens: report.totals.total_tokens, - cost_usd: Some(report.totals.cost_usd), + cost_usd: report.totals.cost_usd, }, } } @@ -632,14 +635,14 @@ mod tests { cache_creation_tokens: 5, cache_read_tokens: 3, total_tokens: 158, - total_cost: 1.25, + total_cost: Some(1.25), model_breakdowns: vec![CcClaudeModel { model_name: "claude-opus-4".to_string(), input_tokens: 100, output_tokens: 50, cache_creation_tokens: 5, cache_read_tokens: 3, - cost: 1.25, + cost: Some(1.25), }], }], totals: CcClaudeTotals { @@ -648,7 +651,7 @@ mod tests { cache_creation_tokens: 5, cache_read_tokens: 3, total_tokens: 158, - total_cost: 1.25, + total_cost: Some(1.25), }, }; let agent = claude_to_agent(report); @@ -680,7 +683,7 @@ mod tests { output_tokens: 80, reasoning_output_tokens: 20, total_tokens: 340, - cost_usd: 0.5, + cost_usd: Some(0.5), models, }], totals: CcCodexTotals { @@ -689,7 +692,7 @@ mod tests { output_tokens: 80, reasoning_output_tokens: 20, total_tokens: 340, - cost_usd: 0.5, + cost_usd: Some(0.5), }, }; let agent = codex_to_agent(report); @@ -698,4 +701,42 @@ mod tests { assert_eq!(agent.totals.cache_read_tokens, 40); assert_eq!(agent.days[0].models[0].cost_usd, None); } + + #[test] + fn claude_tolerates_null_cost() { + // ccusage emits null cost for models it can't price; the whole report + // must survive instead of failing deserialization and dropping the agent. + let raw = json!({ + "daily": [{ + "date": "2026-06-01", + "inputTokens": 100, + "outputTokens": 50, + "cacheCreationTokens": 0, + "cacheReadTokens": 0, + "totalTokens": 150, + "totalCost": null, + "modelBreakdowns": [{ + "modelName": "claude-future", + "inputTokens": 100, + "outputTokens": 50, + "cacheCreationTokens": 0, + "cacheReadTokens": 0, + "cost": null + }] + }], + "totals": { + "inputTokens": 100, + "outputTokens": 50, + "cacheCreationTokens": 0, + "cacheReadTokens": 0, + "totalTokens": 150, + "totalCost": null + } + }); + let report: CcClaudeReport = serde_json::from_value(raw).unwrap(); + let agent = claude_to_agent(report); + assert_eq!(agent.days[0].cost_usd, None); + assert_eq!(agent.days[0].models[0].cost_usd, None); + assert_eq!(agent.totals.cost_usd, None); + } } From 05739036c910d45bc61183851afdde30901ec986 Mon Sep 17 00:00:00 2001 From: Flacier Date: Thu, 4 Jun 2026 15:14:16 +0800 Subject: [PATCH 09/13] fix(usage): enable tokio "macros" feature for tokio::join! - 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" --- crates/usage/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/usage/Cargo.toml b/crates/usage/Cargo.toml index 6f90ac44..ea62eb86 100644 --- a/crates/usage/Cargo.toml +++ b/crates/usage/Cargo.toml @@ -11,7 +11,7 @@ serde = { workspace = true } serde_json = { workspace = true } ts-rs = { workspace = true } dirs = { workspace = true } -tokio = { version = "1", features = [ "process", "time" ] } +tokio = { version = "1", features = [ "process", "time", "macros" ] } reqwest = { version = "0.13", features = [ "json" ] } chrono = "0.4" keyring = { version = "3", features = [ From 13934f1b13a765d6314cd9af98f3b18318f42f6b Mon Sep 17 00:00:00 2001 From: Flacier Date: Thu, 4 Jun 2026 15:14:30 +0800 Subject: [PATCH 10/13] fix(usage): redact filesystem paths from errors and reap timed-out ccusage - 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 --- crates/usage/src/lib.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/usage/src/lib.rs b/crates/usage/src/lib.rs index 2134ea9a..a6ae9fbd 100644 --- a/crates/usage/src/lib.rs +++ b/crates/usage/src/lib.rs @@ -39,13 +39,14 @@ async fn run_ccusage( bin: &OsStr, args: Vec, ) -> Result, String> { - let run = tokio::process::Command::new(bin).args(&args).output(); + let run = tokio::process::Command::new(bin) + .kill_on_drop(true) + .args(&args) + .output(); let output = tokio::time::timeout(CCUSAGE_TIMEOUT, run) .await .map_err(|_| "ccusage timed out after 30s".to_string())? - .map_err(|e| { - format!("failed to spawn ccusage ({}): {e}", bin.to_string_lossy()) - })?; + .map_err(|e| format!("failed to spawn ccusage: {e}"))?; if !output.status.success() { return Err(format!( "ccusage {:?} exited with {}: {}", @@ -384,7 +385,7 @@ fn claude_access_token() -> Result { let path = home_dir()?.join(".claude/.credentials.json"); let json = std::fs::read_to_string(&path) - .map_err(|e| format!("read {}: {e}", path.display()))?; + .map_err(|e| format!("read claude credentials: {e}"))?; parse(&json) } @@ -402,7 +403,7 @@ fn codex_auth() -> Result<(String, Option), String> { } let path = home_dir()?.join(".codex/auth.json"); let json = std::fs::read_to_string(&path) - .map_err(|e| format!("read {}: {e}", path.display()))?; + .map_err(|e| format!("read codex auth: {e}"))?; serde_json::from_str::(&json) .map(|a| (a.tokens.access_token, a.tokens.account_id)) .map_err(|e| format!("parse codex auth: {e}")) From f0fed1b7c47c5b96c0dcd2fdff47b392d818a543 Mon Sep 17 00:00:00 2001 From: Flacier Date: Thu, 4 Jun 2026 15:18:08 +0800 Subject: [PATCH 11/13] fix(desktop): keep platform exe suffix when resolving ccusage sidecar - Tauri strips the - 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 --- crates/desktop/src-tauri/src/commands/server.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/desktop/src-tauri/src/commands/server.rs b/crates/desktop/src-tauri/src/commands/server.rs index 026be47e..8b2b75de 100644 --- a/crates/desktop/src-tauri/src/commands/server.rs +++ b/crates/desktop/src-tauri/src/commands/server.rs @@ -19,9 +19,10 @@ fn find_available_port() -> Result { /// 2. dev build (`tauri dev` / `cargo run`) — no sidecar is staged next to the /// executable, so return `None` and let the API fall back to `ccusage` on PATH. /// 3. packaged build — the sidecar ships beside the main executable as `ccusage` -/// (Tauri strips the `-` suffix at bundle time). We trust the computed -/// path: if the fetch step was skipped the API surfaces a clear spawn error -/// rather than us silently masking it with a PATH fallback. +/// plus the platform's executable extension (Tauri strips the `-` +/// suffix but keeps the extension at bundle time). We trust the computed path: +/// if the fetch step was skipped the API surfaces a clear spawn error rather +/// than us silently masking it with a PATH fallback. /// /// `tauri::process::current_binary` is preferred over std's `current_exe` /// because it returns the real path under AppImage (not the temp mountpoint). @@ -34,7 +35,11 @@ fn resolve_ccusage_bin(app: &tauri::AppHandle) -> Option { } 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)) + }) + }) } #[tauri::command] From ec389f51fc5bf0de90c42dece112c2a7d6289c89 Mon Sep 17 00:00:00 2001 From: Flacier Date: Thu, 4 Jun 2026 15:18:31 +0800 Subject: [PATCH 12/13] fix(desktop): re-fetch ccusage sidecar when CCUSAGE_VERSION changes - Tauri resolves the sidecar by its fixed ccusage- 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-.version stamp and re-fetch whenever it no longer matches - fix the .gitignore comment that still referenced the removed scripts/fetch-ccusage.mjs --- .gitignore | 2 +- crates/desktop/src-tauri/build.rs | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 65c1feb7..9b00b15b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,5 @@ build/ crates/api/bindings/ crates/usage/bindings/ -# ccusage sidecar binaries — fetched at build time by scripts/fetch-ccusage.mjs +# ccusage sidecar binaries — fetched at build time by src-tauri/build.rs crates/desktop/src-tauri/binaries/ diff --git a/crates/desktop/src-tauri/build.rs b/crates/desktop/src-tauri/build.rs index bc650a87..ea6f6e9e 100644 --- a/crates/desktop/src-tauri/build.rs +++ b/crates/desktop/src-tauri/build.rs @@ -35,9 +35,14 @@ fn fetch_ccusage_sidecar() { let binaries_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("binaries"); let dest = binaries_dir.join(format!("ccusage-{triple}{ext}")); + let stamp = binaries_dir.join(format!(".ccusage-{triple}.version")); - // Filename is version-independent, so bumping CCUSAGE_VERSION needs binaries/ cleared. - if dest.exists() { + // Tauri resolves the sidecar by its fixed `ccusage-` name, so the + // filename can't carry the version. Track the staged version in a sibling + // stamp file and re-fetch whenever CCUSAGE_VERSION changes. + if dest.exists() + && fs::read_to_string(&stamp).ok().as_deref() == Some(CCUSAGE_VERSION) + { return; } @@ -86,6 +91,8 @@ fn fetch_ccusage_sidecar() { fs::set_permissions(&dest, fs::Permissions::from_mode(0o755)) .expect("chmod sidecar"); } + + fs::write(&stamp, CCUSAGE_VERSION).expect("write ccusage version stamp"); } fn main() { From 374f446694d003b0a7e99dec2c7f303c4fd4e63a Mon Sep 17 00:00:00 2001 From: Flacier Date: Thu, 4 Jun 2026 15:23:11 +0800 Subject: [PATCH 13/13] build(desktop): verify ccusage tarball integrity against npm registry - 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- 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 --- Cargo.lock | 2 + crates/desktop/src-tauri/Cargo.toml | 3 ++ crates/desktop/src-tauri/build.rs | 57 +++++++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d6057552..f1c81a8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,7 @@ name = "aghub" version = "0.0.0" dependencies = [ "aghub-api", + "base64 0.22.1", "dotenvy", "fix-path-env", "flate2", @@ -31,6 +32,7 @@ dependencies = [ "posthog-rs", "serde", "serde_json", + "sha2 0.10.9", "tar", "tauri", "tauri-build", diff --git a/crates/desktop/src-tauri/Cargo.toml b/crates/desktop/src-tauri/Cargo.toml index b69e71f7..6c7522be 100644 --- a/crates/desktop/src-tauri/Cargo.toml +++ b/crates/desktop/src-tauri/Cargo.toml @@ -19,6 +19,9 @@ tauri-build = { version = "2", features = [ ] } ureq = "2" flate2 = "1.0" tar = "0.4" +serde_json = "1" +sha2 = "0.10" +base64 = "0.22" [dependencies] tauri = { version = "2", features = [ ] } diff --git a/crates/desktop/src-tauri/build.rs b/crates/desktop/src-tauri/build.rs index ea6f6e9e..fbee3bc2 100644 --- a/crates/desktop/src-tauri/build.rs +++ b/crates/desktop/src-tauri/build.rs @@ -48,12 +48,32 @@ fn fetch_ccusage_sidecar() { fs::create_dir_all(&binaries_dir).expect("create binaries dir"); + // Read the tarball URL and Subresource Integrity hash from the registry + // rather than hardcoding either. The hash ships with the package metadata, + // so a bumped CCUSAGE_VERSION verifies against its own published integrity. let pkg = format!("@ccusage/ccusage-{platform}"); - let url = format!( - "https://registry.npmjs.org/{pkg}/-/ccusage-{platform}-{CCUSAGE_VERSION}.tgz" - ); + let meta_url = format!("https://registry.npmjs.org/{pkg}"); + let meta_resp = ureq::get(&meta_url) + .set("Accept", "application/vnd.npm.install-v1+json") + .call() + .unwrap_or_else(|e| panic!("fetch {meta_url} failed: {e}")); + let mut meta_bytes = Vec::new(); + meta_resp + .into_reader() + .read_to_end(&mut meta_bytes) + .expect("read ccusage registry metadata"); + let meta: serde_json::Value = serde_json::from_slice(&meta_bytes) + .expect("parse ccusage registry metadata"); + + let dist = &meta["versions"][CCUSAGE_VERSION]["dist"]; + let url = dist["tarball"].as_str().unwrap_or_else(|| { + panic!("registry has no tarball url for {pkg}@{CCUSAGE_VERSION}") + }); + let integrity = dist["integrity"].as_str().unwrap_or_else(|| { + panic!("registry has no integrity for {pkg}@{CCUSAGE_VERSION}") + }); - let resp = ureq::get(&url) + let resp = ureq::get(url) .call() .unwrap_or_else(|e| panic!("download {url} failed: {e}")); let mut tarball = Vec::new(); @@ -61,6 +81,8 @@ fn fetch_ccusage_sidecar() { .read_to_end(&mut tarball) .expect("read ccusage tarball"); + verify_tarball_integrity(&pkg, &tarball, integrity); + // npm tarball layout: package/bin/ccusage(.exe). let member = format!("package/bin/ccusage{ext}"); let mut archive = tar::Archive::new(GzDecoder::new(&tarball[..])); @@ -95,6 +117,33 @@ fn fetch_ccusage_sidecar() { fs::write(&stamp, CCUSAGE_VERSION).expect("write ccusage version stamp"); } +// Check the tarball against the registry's `sha512-` integrity string, +// so a tampered or truncated download fails the build instead of shipping a +// bad sidecar. +fn verify_tarball_integrity(pkg: &str, tarball: &[u8], integrity: &str) { + use base64::Engine as _; + use sha2::{Digest, Sha512}; + + let expected = + integrity + .split_whitespace() + .find(|hash| hash.starts_with("sha512-")) + .unwrap_or_else(|| { + panic!("{pkg}: registry integrity carries no sha512 hash: {integrity}") + }); + let actual = format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD + .encode(Sha512::digest(tarball)) + ); + if actual != expected { + panic!( + "{pkg}: ccusage tarball integrity mismatch \ + (expected {expected}, computed {actual})" + ); + } +} + fn main() { // Load POSTHOG_KEY / POSTHOG_HOST from the desktop crate's .env so // option_env!() in commands::posthog can embed them. The webview gets