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