diff --git a/.gitignore b/.gitignore index caa37bed..9b00b15b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ build/ # ts-rs generated TypeScript bindings (build artifact) crates/api/bindings/ +crates/usage/bindings/ + +# ccusage sidecar binaries — fetched at build time by src-tauri/build.rs +crates/desktop/src-tauri/binaries/ diff --git a/Cargo.lock b/Cargo.lock index d2d1f8d1..c0ece10f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,13 +24,17 @@ name = "aghub" version = "0.0.0" dependencies = [ "aghub-api", + "base64 0.22.1", "dotenvy", "fix-path-env", + "flate2", "log", "posthog-rs", "serde", "serde_json", + "sha2 0.10.9", "sys-locale", + "tar", "tauri", "tauri-build", "tauri-plugin-autostart", @@ -46,6 +50,7 @@ dependencies = [ "tempfile", "time", "tokio", + "ureq", "uuid", "zip 8.6.0", ] @@ -74,6 +79,8 @@ dependencies = [ "aghub-core", "aghub-git", "aghub-inference", + "aghub-usage", + "chrono", "dirs 6.0.0", "keyring", "log", @@ -123,6 +130,7 @@ dependencies = [ "aghub-agents", "aghub-cc-plugins", "aghub-core", + "aghub-usage", "anyhow", "assert_cmd", "clap", @@ -201,6 +209,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "aghub-usage" +version = "1.1.1" +dependencies = [ + "chrono", + "dirs 6.0.0", + "keyring", + "reqwest", + "serde", + "serde_json", + "tokio", + "ts-rs", +] + [[package]] name = "ahash" version = "0.7.8" @@ -6336,6 +6358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -8664,6 +8687,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" @@ -9103,6 +9142,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/Cargo.toml b/Cargo.toml index 028aeb2e..6917a236 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 87b2fc56..4201632f 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -20,11 +20,12 @@ 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" ] } rocket_cors = "0.6.0" -tokio = { workspace = true, features = [ "rt-multi-thread", "macros", "process" ] } +tokio = { workspace = true, features = [ "rt-multi-thread", "macros", "process", "time" ] } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } @@ -37,4 +38,5 @@ keyring = { workspace = true } uuid = { workspace = true } log = { workspace = true } reqwest = { workspace = true } +chrono = { workspace = true } url = { workspace = true } diff --git a/crates/api/src/bin/export-dto.rs b/crates/api/src/bin/export-dto.rs index 52a16ff4..1b43b21a 100644 --- a/crates/api/src/bin/export-dto.rs +++ b/crates/api/src/bin/export-dto.rs @@ -70,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 { @@ -247,6 +251,16 @@ 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)?; + + export_type::(&cfg)?; + export_type::(&cfg)?; + export_type::(&cfg)?; + write_index_file(&out_dir)?; if disallowed_dir.exists() { diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 4a8a0f8b..dbda7966 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -23,6 +23,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, pub auth_token: Option, pub allowed_origins: Vec, pub allowed_origin_regexes: Vec, @@ -33,6 +35,7 @@ impl ApiOptions { Self { port, app_data_dir: None, + ccusage_bin: None, auth_token: None, allowed_origins: default_allowed_origins(), allowed_origin_regexes: default_allowed_origin_regexes(), @@ -58,6 +61,7 @@ impl ApiOptions { app_data_dir: self .app_data_dir .unwrap_or_else(default_app_data_dir), + ccusage_bin: self.ccusage_bin, auth_token, token_was_generated, allowed_origins: self.allowed_origins, @@ -87,6 +91,7 @@ fn default_allowed_origin_regexes() -> Vec { struct ResolvedApiOptions { port: u16, app_data_dir: PathBuf, + ccusage_bin: Option, auth_token: String, token_was_generated: bool, allowed_origins: Vec, @@ -183,6 +188,9 @@ fn build_rocket( .manage(crate::state::InferenceProviderState { app_data_dir: options.app_data_dir, }) + .manage(crate::state::UsageState { + ccusage_bin: options.ccusage_bin, + }) .manage(crate::auth::ApiAuthState { token: options.auth_token, }) @@ -286,6 +294,8 @@ fn build_rocket( routes::plugins::cli_status, routes::plugins::prune_plugins, routes::plugins::validate_plugin, + routes::usage::usage_summary, + routes::usage::usage_limits, ], ) .register( 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..4c19e276 --- /dev/null +++ b/crates/api/src/routes/usage.rs @@ -0,0 +1,27 @@ +//! 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 aghub_usage::{UsageLimitsReportDto, UsageReportDto}; + +use crate::state::UsageState; + +/// `GET /api/v1/usage/summary` — daily token/cost usage for Claude and Codex. +#[get("/usage/summary?&&")] +pub async fn usage_summary( + usage: &State, + since: Option, + until: Option, + timezone: Option, +) -> Json { + 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. +#[get("/usage/limits")] +pub async fn usage_limits() -> Json { + Json(aghub_usage::limits().await) +} diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index 07769899..b44cfed1 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -26,3 +26,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/cli/Cargo.toml b/crates/cli/Cargo.toml index ce579155..2bbe8139 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") + } } } diff --git a/crates/desktop/src-tauri/Cargo.toml b/crates/desktop/src-tauri/Cargo.toml index 2a17c126..4a61428a 100644 --- a/crates/desktop/src-tauri/Cargo.toml +++ b/crates/desktop/src-tauri/Cargo.toml @@ -16,6 +16,12 @@ crate-type = [ "staticlib", "cdylib", "rlib" ] [build-dependencies] dotenvy = "0.15.7" tauri-build = { version = "2", features = [ ] } +ureq = "2" +flate2 = "1.0" +tar = "0.4" +serde_json = "1" +sha2 = "0.10" +base64 = "0.22" [dependencies] tauri = { workspace = true, features = [ "tray-icon" ] } diff --git a/crates/desktop/src-tauri/build.rs b/crates/desktop/src-tauri/build.rs index ac6f74a1..fbee3bc2 100644 --- a/crates/desktop/src-tauri/build.rs +++ b/crates/desktop/src-tauri/build.rs @@ -1,4 +1,148 @@ -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}")); + let stamp = binaries_dir.join(format!(".ccusage-{triple}.version")); + + // 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; + } + + 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 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) + .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"); + + 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[..])); + 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"); + } + + 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 @@ -16,5 +160,6 @@ fn main() { } } - tauri_build::build() + fetch_ccusage_sidecar(); + tauri_build::build(); } diff --git a/crates/desktop/src-tauri/src/commands/server.rs b/crates/desktop/src-tauri/src/commands/server.rs index e73369f5..0920d3c2 100644 --- a/crates/desktop/src-tauri/src/commands/server.rs +++ b/crates/desktop/src-tauri/src/commands/server.rs @@ -1,7 +1,8 @@ use crate::AppState; use aghub_api::{start, ApiOptions}; -use log::{debug, error, info}; +use log::{debug, error, info, warn}; use serde::Serialize; +use std::path::PathBuf; use tauri::Manager; #[derive(Clone, Serialize)] @@ -18,12 +19,47 @@ 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` +/// 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). +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(format!("ccusage{}", std::env::consts::EXE_SUFFIX)) + }) + }) +} + #[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 server = { let mut guard = state.server.lock().unwrap(); if let Some(server) = guard.as_ref() { @@ -47,6 +83,7 @@ pub async fn start_server( let mut options = ApiOptions::new(port); options.app_data_dir = Some(app_data_dir); options.auth_token = Some(token); + options.ccusage_bin = ccusage_bin; if let Err(error) = start(options).await { error!("embedded API server exited with error: {error}"); } diff --git a/crates/desktop/src-tauri/tauri.conf.json b/crates/desktop/src-tauri/tauri.conf.json index b33612f3..617e4b5b 100644 --- a/crates/desktop/src-tauri/tauri.conf.json +++ b/crates/desktop/src-tauri/tauri.conf.json @@ -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/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/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/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/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/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/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..7e7e7887 --- /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 + * `claude_to_agent` / `codex_to_agent`. + */ +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..f1f51576 100644 --- a/crates/desktop/src/generated/dto/index.ts +++ b/crates/desktop/src/generated/dto/index.ts @@ -1,10 +1,12 @@ 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"; 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"; @@ -72,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"; @@ -109,4 +112,9 @@ 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 { UsageLimitsReportDto } from "./UsageLimitsReportDto"; +export type { UsageModelDto } from "./UsageModelDto"; +export type { UsageReportDto } from "./UsageReportDto"; +export type { UsageTotalsDto } from "./UsageTotalsDto"; export type { ValidationError } from "./ValidationError"; diff --git a/crates/usage/Cargo.toml b/crates/usage/Cargo.toml new file mode 100644 index 00000000..ea62eb86 --- /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", "macros" ] } +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/usage/src/dto.rs b/crates/usage/src/dto.rs new file mode 100644 index 00000000..9426ac9e --- /dev/null +++ b/crates/usage/src/dto.rs @@ -0,0 +1,108 @@ +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 +/// `claude_to_agent` / `codex_to_agent`. +#[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, +} + +/// 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/usage/src/lib.rs b/crates/usage/src/lib.rs new file mode 100644 index 00000000..a6ae9fbd --- /dev/null +++ b/crates/usage/src/lib.rs @@ -0,0 +1,743 @@ +//! 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) + .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}"))?; + 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, + #[serde(default)] + total_cost: Option, + #[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, + #[serde(default)] + cost: Option, +} + +#[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, + #[serde(default)] + total_cost: Option, +} + +// ---- 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(default, rename = "costUSD")] + cost_usd: Option, + #[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(default, rename = "costUSD")] + cost_usd: Option, +} + +// ---- 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: 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: 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: 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: 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: 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 claude credentials: {e}"))?; + 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 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}")) +} + +// ---- 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: 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: Some(1.25), + }], + }], + totals: CcClaudeTotals { + input_tokens: 100, + output_tokens: 50, + cache_creation_tokens: 5, + cache_read_tokens: 3, + total_tokens: 158, + total_cost: Some(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: Some(0.5), + models, + }], + totals: CcCodexTotals { + input_tokens: 200, + cached_input_tokens: 40, + output_tokens: 80, + reasoning_output_tokens: 20, + total_tokens: 340, + cost_usd: Some(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); + } + + #[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); + } +}