use std::process::Stdio;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use crate::agent::Agent;
use crate::error::{Error, Result};
const QUERY_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_REPLY_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AccountUsage {
pub account_kind: Option<String>,
pub plan: Option<String>,
pub email: Option<String>,
pub windows: Vec<UsageWindow>,
pub credits: Option<Credits>,
pub lifetime: Option<Lifetime>,
pub daily: Vec<DailyUsage>,
pub spend_control_reached: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UsageWindow {
pub id: String,
pub used_percent: Option<f64>,
pub window_minutes: Option<u64>,
pub resets_at: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Credits {
pub has_credits: bool,
pub unlimited: bool,
pub balance: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Lifetime {
pub tokens: Option<u64>,
pub peak_daily_tokens: Option<u64>,
pub longest_turn_secs: Option<u64>,
pub current_streak_days: Option<u64>,
pub longest_streak_days: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct DailyUsage {
pub date: String,
pub tokens: Option<u64>,
}
impl Agent {
#[must_use]
pub fn reports_account_usage(self) -> bool {
matches!(self, Agent::Codex)
}
pub async fn account_usage(self) -> Result<AccountUsage> {
match self {
Agent::Codex => codex_account_usage(self.bin()).await,
Agent::Claude | Agent::Copilot => Err(Error::Unsupported {
agent: self,
what: "reporting account usage without a terminal",
}),
}
}
}
async fn codex_account_usage(bin: &str) -> Result<AccountUsage> {
let mut child = tokio::process::Command::new(bin)
.arg("app-server")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
Error::NotInstalled {
agent: Agent::Codex,
bin: bin.to_string(),
hint: Agent::Codex.install_hint(),
}
} else {
Error::Spawn {
bin: bin.to_string(),
source,
}
}
})?;
let exchange = codex_exchange(&mut child);
let result = match tokio::time::timeout(QUERY_TIMEOUT, exchange).await {
Ok(result) => result,
Err(_) => Err(Error::Timeout {
bin: bin.to_string(),
timeout: QUERY_TIMEOUT,
partial: String::new(),
}),
};
let _ = child.kill().await;
result
}
async fn codex_exchange(child: &mut tokio::process::Child) -> Result<AccountUsage> {
const ACCOUNT: i64 = 2;
const LIMITS: i64 = 3;
const USAGE: i64 = 4;
let Some(stdin) = child.stdin.as_mut() else {
return Err(Error::Parse {
agent: Agent::Codex,
detail: "app-server stdin was not available".into(),
});
};
let mut batch = String::new();
batch.push_str(&request(
1,
"initialize",
&serde_json::json!({"clientInfo": {
"name": "agent-abstraction",
"title": "agent-abstraction",
"version": env!("CARGO_PKG_VERSION"),
}}),
));
for (id, method) in [
(ACCOUNT, "account/read"),
(LIMITS, "account/rateLimits/read"),
(USAGE, "account/usage/read"),
] {
batch.push_str(&request(id, method, &serde_json::json!({})));
}
stdin
.write_all(batch.as_bytes())
.await
.map_err(|source| Error::Spawn {
bin: "codex app-server".into(),
source,
})?;
let _ = stdin.flush().await;
let Some(stdout) = child.stdout.take() else {
return Err(Error::Parse {
agent: Agent::Codex,
detail: "app-server stdout was not available".into(),
});
};
let mut lines = BufReader::new(stdout).lines();
let mut usage = AccountUsage::default();
let mut outstanding = 3;
while outstanding > 0 {
let Ok(Some(line)) = lines.next_line().await else {
break;
};
if line.len() > MAX_REPLY_BYTES {
continue;
}
let Ok(value) = serde_json::from_str::<Value>(&line) else {
continue;
};
let Some(id) = value.get("id").and_then(Value::as_i64) else {
continue;
};
if !matches!(id, ACCOUNT | LIMITS | USAGE) {
continue;
}
outstanding -= 1;
if let Some(error) = value.get("error") {
let message = error
.get("message")
.and_then(Value::as_str)
.unwrap_or("the app-server refused the request");
return Err(Error::AgentError {
agent: Agent::Codex,
bin: "codex app-server".into(),
status: None,
message: message.chars().take(400).collect(),
});
}
let Some(result) = value.get("result") else {
continue;
};
match id {
ACCOUNT => read_account(&mut usage, result),
LIMITS => read_limits(&mut usage, result),
USAGE => read_usage(&mut usage, result),
_ => unreachable!("filtered above"),
}
}
if usage == AccountUsage::default() {
return Err(Error::Parse {
agent: Agent::Codex,
detail: "app-server reported no account information".into(),
});
}
Ok(usage)
}
fn request(id: i64, method: &str, params: &Value) -> String {
format!(
"{}\n",
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})
)
}
fn read_account(usage: &mut AccountUsage, result: &Value) {
let Some(account) = result.get("account") else {
return;
};
let text = |key: &str| account.get(key).and_then(Value::as_str).map(str::to_string);
usage.account_kind = text("type");
usage.plan = text("planType");
usage.email = text("email");
}
fn read_limits(usage: &mut AccountUsage, result: &Value) {
let Some(limits) = result.get("rateLimits") else {
return;
};
for id in ["primary", "secondary"] {
let Some(window) = limits.get(id).filter(|w| !w.is_null()) else {
continue;
};
usage.windows.push(UsageWindow {
id: id.to_string(),
used_percent: window.get("usedPercent").and_then(Value::as_f64),
window_minutes: window.get("windowDurationMins").and_then(Value::as_u64),
resets_at: window.get("resetsAt").and_then(Value::as_i64),
});
}
if let Some(credits) = limits.get("credits").filter(|c| !c.is_null()) {
usage.credits = Some(Credits {
has_credits: credits
.get("hasCredits")
.and_then(Value::as_bool)
.unwrap_or(false),
unlimited: credits
.get("unlimited")
.and_then(Value::as_bool)
.unwrap_or(false),
balance: credits
.get("balance")
.and_then(Value::as_str)
.map(str::to_string),
});
}
usage.spend_control_reached = limits.get("spendControlReached").and_then(Value::as_bool);
if usage.plan.is_none() {
usage.plan = limits
.get("planType")
.and_then(Value::as_str)
.map(str::to_string);
}
}
fn read_usage(usage: &mut AccountUsage, result: &Value) {
if let Some(summary) = result.get("summary") {
let get = |key: &str| summary.get(key).and_then(Value::as_u64);
usage.lifetime = Some(Lifetime {
tokens: get("lifetimeTokens"),
peak_daily_tokens: get("peakDailyTokens"),
longest_turn_secs: get("longestRunningTurnSec"),
current_streak_days: get("currentStreakDays"),
longest_streak_days: get("longestStreakDays"),
});
}
if let Some(buckets) = result.get("dailyUsageBuckets").and_then(Value::as_array) {
usage.daily = buckets
.iter()
.filter_map(|bucket| {
Some(DailyUsage {
date: bucket.get("startDate").and_then(Value::as_str)?.to_string(),
tokens: bucket.get("tokens").and_then(Value::as_u64),
})
})
.collect();
}
}
#[cfg(test)]
mod tests {
use super::*;
const LIMITS: &str = r#"{"rateLimits":{"limitId":"codex","primary":{"usedPercent":1,
"windowDurationMins":10080,"resetsAt":1785925265},"secondary":null,
"credits":{"hasCredits":false,"unlimited":false,"balance":"0"},
"spendControlReached":false,"planType":"plus"}}"#;
#[test]
fn rate_limits_carry_the_window_and_its_reset() {
let mut usage = AccountUsage::default();
read_limits(&mut usage, &serde_json::from_str(LIMITS).expect("json"));
assert_eq!(usage.windows.len(), 1, "secondary is null on this plan");
let window = &usage.windows[0];
assert_eq!(window.id, "primary");
assert_eq!(window.used_percent, Some(1.0));
assert_eq!(window.window_minutes, Some(10080));
assert_eq!(window.resets_at, Some(1_785_925_265));
assert_eq!(usage.spend_control_reached, Some(false));
}
#[test]
fn a_credit_balance_stays_exactly_as_the_provider_wrote_it() {
let mut usage = AccountUsage::default();
read_limits(&mut usage, &serde_json::from_str(LIMITS).expect("json"));
let credits = usage.credits.expect("credits");
assert_eq!(credits.balance.as_deref(), Some("0"));
assert!(!credits.has_credits);
assert!(!credits.unlimited);
}
#[test]
fn a_null_window_is_skipped_not_invented() {
let both =
r#"{"rateLimits":{"primary":{"usedPercent":12},"secondary":{"usedPercent":40}}}"#;
let mut usage = AccountUsage::default();
read_limits(&mut usage, &serde_json::from_str(both).expect("json"));
assert_eq!(usage.windows.len(), 2);
assert_eq!(usage.windows[1].id, "secondary");
assert_eq!(usage.windows[1].used_percent, Some(40.0));
}
#[test]
fn lifetime_and_daily_totals_are_read() {
let reply = r#"{"summary":{"lifetimeTokens":1243297,"peakDailyTokens":1060227,
"longestRunningTurnSec":11,"currentStreakDays":2,"longestStreakDays":2},
"dailyUsageBuckets":[{"startDate":"2026-07-28","tokens":1060227},
{"startDate":"2026-07-29","tokens":183070}]}"#;
let mut usage = AccountUsage::default();
read_usage(&mut usage, &serde_json::from_str(reply).expect("json"));
let lifetime = usage.lifetime.expect("lifetime");
assert_eq!(lifetime.tokens, Some(1_243_297));
assert_eq!(lifetime.current_streak_days, Some(2));
assert_eq!(usage.daily.len(), 2);
assert_eq!(usage.daily[1].date, "2026-07-29");
assert_eq!(usage.daily[1].tokens, Some(183_070));
}
#[tokio::test]
async fn agents_that_cannot_report_say_so_without_being_asked_twice() {
for agent in [Agent::Claude, Agent::Copilot] {
assert!(!agent.reports_account_usage(), "{agent}");
assert!(
matches!(agent.account_usage().await, Err(Error::Unsupported { .. })),
"{agent} should refuse rather than assemble a partial answer"
);
}
assert!(Agent::Codex.reports_account_usage());
}
}