1use std::process::Stdio;
35use std::time::Duration;
36
37use serde::{Deserialize, Serialize};
38use serde_json::Value;
39use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
40
41use crate::agent::Agent;
42use crate::error::{Error, Result};
43
44const QUERY_TIMEOUT: Duration = Duration::from_secs(30);
49
50const MAX_REPLY_BYTES: usize = 1024 * 1024;
52
53#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
58#[non_exhaustive]
59pub struct AccountUsage {
60 pub account_kind: Option<String>,
62 pub plan: Option<String>,
64 pub email: Option<String>,
66 pub windows: Vec<UsageWindow>,
69 pub credits: Option<Credits>,
71 pub lifetime: Option<Lifetime>,
73 pub daily: Vec<DailyUsage>,
75 pub spend_control_reached: Option<bool>,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[non_exhaustive]
82pub struct UsageWindow {
83 pub id: String,
85 pub used_percent: Option<f64>,
87 pub window_minutes: Option<u64>,
89 pub resets_at: Option<i64>,
91}
92
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95#[non_exhaustive]
96pub struct Credits {
97 pub has_credits: bool,
99 pub unlimited: bool,
101 pub balance: Option<String>,
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
109#[non_exhaustive]
110pub struct Lifetime {
111 pub tokens: Option<u64>,
113 pub peak_daily_tokens: Option<u64>,
115 pub longest_turn_secs: Option<u64>,
117 pub current_streak_days: Option<u64>,
119 pub longest_streak_days: Option<u64>,
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[non_exhaustive]
126pub struct DailyUsage {
127 pub date: String,
129 pub tokens: Option<u64>,
131}
132
133impl Agent {
134 #[must_use]
139 pub fn reports_account_usage(self) -> bool {
140 matches!(self, Agent::Codex)
141 }
142
143 pub async fn account_usage(self) -> Result<AccountUsage> {
154 match self {
155 Agent::Codex => codex_account_usage(self.bin()).await,
156 Agent::Claude | Agent::Copilot => Err(Error::Unsupported {
160 agent: self,
161 what: "reporting account usage without a terminal",
162 }),
163 }
164 }
165}
166
167async fn codex_account_usage(bin: &str) -> Result<AccountUsage> {
176 let mut child = tokio::process::Command::new(bin)
177 .arg("app-server")
178 .stdin(Stdio::piped())
179 .stdout(Stdio::piped())
180 .stderr(Stdio::null())
183 .spawn()
184 .map_err(|source| {
185 if source.kind() == std::io::ErrorKind::NotFound {
186 Error::NotInstalled {
187 agent: Agent::Codex,
188 bin: bin.to_string(),
189 hint: Agent::Codex.install_hint(),
190 }
191 } else {
192 Error::Spawn {
193 bin: bin.to_string(),
194 source,
195 }
196 }
197 })?;
198
199 let exchange = codex_exchange(&mut child);
200 let result = match tokio::time::timeout(QUERY_TIMEOUT, exchange).await {
201 Ok(result) => result,
202 Err(_) => Err(Error::Timeout {
203 bin: bin.to_string(),
204 timeout: QUERY_TIMEOUT,
205 partial: String::new(),
206 }),
207 };
208 let _ = child.kill().await;
211 result
212}
213
214async fn codex_exchange(child: &mut tokio::process::Child) -> Result<AccountUsage> {
216 const ACCOUNT: i64 = 2;
217 const LIMITS: i64 = 3;
218 const USAGE: i64 = 4;
219
220 let Some(stdin) = child.stdin.as_mut() else {
221 return Err(Error::Parse {
222 agent: Agent::Codex,
223 detail: "app-server stdin was not available".into(),
224 });
225 };
226 let mut batch = String::new();
229 batch.push_str(&request(
230 1,
231 "initialize",
232 &serde_json::json!({"clientInfo": {
233 "name": "agent-abstraction",
234 "title": "agent-abstraction",
235 "version": env!("CARGO_PKG_VERSION"),
236 }}),
237 ));
238 for (id, method) in [
239 (ACCOUNT, "account/read"),
240 (LIMITS, "account/rateLimits/read"),
241 (USAGE, "account/usage/read"),
242 ] {
243 batch.push_str(&request(id, method, &serde_json::json!({})));
244 }
245 stdin
246 .write_all(batch.as_bytes())
247 .await
248 .map_err(|source| Error::Spawn {
249 bin: "codex app-server".into(),
250 source,
251 })?;
252 let _ = stdin.flush().await;
253
254 let Some(stdout) = child.stdout.take() else {
255 return Err(Error::Parse {
256 agent: Agent::Codex,
257 detail: "app-server stdout was not available".into(),
258 });
259 };
260
261 let mut lines = BufReader::new(stdout).lines();
262 let mut usage = AccountUsage::default();
263 let mut outstanding = 3;
264 while outstanding > 0 {
265 let Ok(Some(line)) = lines.next_line().await else {
268 break;
269 };
270 if line.len() > MAX_REPLY_BYTES {
271 continue;
272 }
273 let Ok(value) = serde_json::from_str::<Value>(&line) else {
274 continue;
275 };
276 let Some(id) = value.get("id").and_then(Value::as_i64) else {
277 continue;
279 };
280 if !matches!(id, ACCOUNT | LIMITS | USAGE) {
281 continue;
282 }
283 outstanding -= 1;
284 if let Some(error) = value.get("error") {
285 let message = error
286 .get("message")
287 .and_then(Value::as_str)
288 .unwrap_or("the app-server refused the request");
289 return Err(Error::AgentError {
290 agent: Agent::Codex,
291 bin: "codex app-server".into(),
292 status: None,
293 message: message.chars().take(400).collect(),
294 });
295 }
296 let Some(result) = value.get("result") else {
297 continue;
298 };
299 match id {
300 ACCOUNT => read_account(&mut usage, result),
301 LIMITS => read_limits(&mut usage, result),
302 USAGE => read_usage(&mut usage, result),
303 _ => unreachable!("filtered above"),
304 }
305 }
306
307 if usage == AccountUsage::default() {
308 return Err(Error::Parse {
309 agent: Agent::Codex,
310 detail: "app-server reported no account information".into(),
311 });
312 }
313 Ok(usage)
314}
315
316fn request(id: i64, method: &str, params: &Value) -> String {
318 format!(
319 "{}\n",
320 serde_json::json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})
321 )
322}
323
324fn read_account(usage: &mut AccountUsage, result: &Value) {
326 let Some(account) = result.get("account") else {
327 return;
328 };
329 let text = |key: &str| account.get(key).and_then(Value::as_str).map(str::to_string);
330 usage.account_kind = text("type");
331 usage.plan = text("planType");
332 usage.email = text("email");
333}
334
335fn read_limits(usage: &mut AccountUsage, result: &Value) {
337 let Some(limits) = result.get("rateLimits") else {
338 return;
339 };
340 for id in ["primary", "secondary"] {
343 let Some(window) = limits.get(id).filter(|w| !w.is_null()) else {
344 continue;
345 };
346 usage.windows.push(UsageWindow {
347 id: id.to_string(),
348 used_percent: window.get("usedPercent").and_then(Value::as_f64),
349 window_minutes: window.get("windowDurationMins").and_then(Value::as_u64),
350 resets_at: window.get("resetsAt").and_then(Value::as_i64),
351 });
352 }
353 if let Some(credits) = limits.get("credits").filter(|c| !c.is_null()) {
354 usage.credits = Some(Credits {
355 has_credits: credits
356 .get("hasCredits")
357 .and_then(Value::as_bool)
358 .unwrap_or(false),
359 unlimited: credits
360 .get("unlimited")
361 .and_then(Value::as_bool)
362 .unwrap_or(false),
363 balance: credits
364 .get("balance")
365 .and_then(Value::as_str)
366 .map(str::to_string),
367 });
368 }
369 usage.spend_control_reached = limits.get("spendControlReached").and_then(Value::as_bool);
370 if usage.plan.is_none() {
373 usage.plan = limits
374 .get("planType")
375 .and_then(Value::as_str)
376 .map(str::to_string);
377 }
378}
379
380fn read_usage(usage: &mut AccountUsage, result: &Value) {
382 if let Some(summary) = result.get("summary") {
383 let get = |key: &str| summary.get(key).and_then(Value::as_u64);
384 usage.lifetime = Some(Lifetime {
385 tokens: get("lifetimeTokens"),
386 peak_daily_tokens: get("peakDailyTokens"),
387 longest_turn_secs: get("longestRunningTurnSec"),
388 current_streak_days: get("currentStreakDays"),
389 longest_streak_days: get("longestStreakDays"),
390 });
391 }
392 if let Some(buckets) = result.get("dailyUsageBuckets").and_then(Value::as_array) {
393 usage.daily = buckets
394 .iter()
395 .filter_map(|bucket| {
396 Some(DailyUsage {
397 date: bucket.get("startDate").and_then(Value::as_str)?.to_string(),
398 tokens: bucket.get("tokens").and_then(Value::as_u64),
399 })
400 })
401 .collect();
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 const LIMITS: &str = r#"{"rateLimits":{"limitId":"codex","primary":{"usedPercent":1,
411 "windowDurationMins":10080,"resetsAt":1785925265},"secondary":null,
412 "credits":{"hasCredits":false,"unlimited":false,"balance":"0"},
413 "spendControlReached":false,"planType":"plus"}}"#;
414
415 #[test]
416 fn rate_limits_carry_the_window_and_its_reset() {
417 let mut usage = AccountUsage::default();
418 read_limits(&mut usage, &serde_json::from_str(LIMITS).expect("json"));
419 assert_eq!(usage.windows.len(), 1, "secondary is null on this plan");
420 let window = &usage.windows[0];
421 assert_eq!(window.id, "primary");
422 assert_eq!(window.used_percent, Some(1.0));
423 assert_eq!(window.window_minutes, Some(10080));
425 assert_eq!(window.resets_at, Some(1_785_925_265));
426 assert_eq!(usage.spend_control_reached, Some(false));
427 }
428
429 #[test]
431 fn a_credit_balance_stays_exactly_as_the_provider_wrote_it() {
432 let mut usage = AccountUsage::default();
433 read_limits(&mut usage, &serde_json::from_str(LIMITS).expect("json"));
434 let credits = usage.credits.expect("credits");
435 assert_eq!(credits.balance.as_deref(), Some("0"));
436 assert!(!credits.has_credits);
437 assert!(!credits.unlimited);
438 }
439
440 #[test]
443 fn a_null_window_is_skipped_not_invented() {
444 let both =
445 r#"{"rateLimits":{"primary":{"usedPercent":12},"secondary":{"usedPercent":40}}}"#;
446 let mut usage = AccountUsage::default();
447 read_limits(&mut usage, &serde_json::from_str(both).expect("json"));
448 assert_eq!(usage.windows.len(), 2);
449 assert_eq!(usage.windows[1].id, "secondary");
450 assert_eq!(usage.windows[1].used_percent, Some(40.0));
451 }
452
453 #[test]
454 fn lifetime_and_daily_totals_are_read() {
455 let reply = r#"{"summary":{"lifetimeTokens":1243297,"peakDailyTokens":1060227,
456 "longestRunningTurnSec":11,"currentStreakDays":2,"longestStreakDays":2},
457 "dailyUsageBuckets":[{"startDate":"2026-07-28","tokens":1060227},
458 {"startDate":"2026-07-29","tokens":183070}]}"#;
459 let mut usage = AccountUsage::default();
460 read_usage(&mut usage, &serde_json::from_str(reply).expect("json"));
461 let lifetime = usage.lifetime.expect("lifetime");
462 assert_eq!(lifetime.tokens, Some(1_243_297));
463 assert_eq!(lifetime.current_streak_days, Some(2));
464 assert_eq!(usage.daily.len(), 2);
465 assert_eq!(usage.daily[1].date, "2026-07-29");
466 assert_eq!(usage.daily[1].tokens, Some(183_070));
467 }
468
469 #[tokio::test]
472 async fn agents_that_cannot_report_say_so_without_being_asked_twice() {
473 for agent in [Agent::Claude, Agent::Copilot] {
474 assert!(!agent.reports_account_usage(), "{agent}");
475 assert!(
476 matches!(agent.account_usage().await, Err(Error::Unsupported { .. })),
477 "{agent} should refuse rather than assemble a partial answer"
478 );
479 }
480 assert!(Agent::Codex.reports_account_usage());
481 }
482}