Skip to main content

agent_abstraction/
account.rs

1//! Account-wide usage, for a host that wants to show quota rather than guess.
2//!
3//! Distinct from [`crate::Usage`], which measures one run. This is the plan
4//! behind the runs: how much of a window is spent, when it resets, what credits
5//! remain.
6//!
7//! # Only Codex can answer
8//!
9//! Of the three, one exposes this without a terminal.
10//!
11//! - **Codex** answers in full through `codex app-server`, a JSON-RPC interface
12//!   over stdio. Percentages, window lengths, reset times, per-day buckets and
13//!   lifetime totals.
14//! - **Claude** reports quota only *during* a run, as
15//!   [`crate::Event::RateLimit`], and the wire carries no utilization figure at
16//!   all. Verified against claude 2.1.212: the whole `rate_limit_info` vocabulary
17//!   is `status`, `resetsAt`, `rateLimitType`, `overageStatus`,
18//!   `overageDisabledReason` and `isUsingOverage`. There is no percentage field
19//!   to be absent, so no amount of waiting for the right event produces one.
20//!   The percentages on its `/usage` screen are fetched separately by that
21//!   screen, and only rendered there.
22//! - **Copilot** reports session spend as [`crate::Usage::ai_credits_nano`] and
23//!   nothing account-wide. Remaining budget lives in its status footer.
24//!
25//! [`crate::Agent::reports_account_usage`] answers this up front, so a host can
26//! decide whether to build the panel at all rather than discovering it from an
27//! error.
28//!
29//! # Values, not presentation
30//!
31//! Everything here is a number, a string or a timestamp exactly as the provider
32//! gave it. No formatting, no percentages pre-rendered into text, no bars. A
33//! host that wants "16% used" or a progress meter builds it from
34//! [`UsageWindow::used_percent`].
35
36use 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
46/// How long to wait for the whole exchange before giving up.
47///
48/// Generous for three local round trips, and bounded because a helper that
49/// hangs would take a UI thread with it.
50const QUERY_TIMEOUT: Duration = Duration::from_secs(30);
51
52/// Cap on a single reply, so a malformed stream cannot exhaust memory.
53const MAX_REPLY_BYTES: usize = 1024 * 1024;
54
55/// What an account has spent and what it has left.
56///
57/// Every field is optional or empty-able: this is assembled from whatever the
58/// agent reports, and absent means "it did not say", never zero.
59#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
60#[non_exhaustive]
61pub struct AccountUsage {
62    /// How the account authenticates, e.g. `chatgpt`.
63    pub account_kind: Option<String>,
64    /// The plan name the provider reports, e.g. `plus`.
65    pub plan: Option<String>,
66    /// Email on the account, where the agent reports one.
67    pub email: Option<String>,
68    /// Every quota window the provider is tracking, most constraining first as
69    /// the provider ordered them.
70    pub windows: Vec<UsageWindow>,
71    /// Pay-as-you-go balance, where the plan has one.
72    pub credits: Option<Credits>,
73    /// All-time counters, where the agent keeps them.
74    pub lifetime: Option<Lifetime>,
75    /// Per-day token totals, oldest first.
76    pub daily: Vec<DailyUsage>,
77    /// Whether spend controls have already stopped this account.
78    pub spend_control_reached: Option<bool>,
79}
80
81/// One quota window and how much of it is gone.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83#[non_exhaustive]
84pub struct UsageWindow {
85    /// The provider's own name for this window, e.g. `primary`.
86    pub id: String,
87    /// Share of the window consumed, 0 to 100, as the provider reported it.
88    pub used_percent: Option<f64>,
89    /// How long the window runs. Codex reports 10080 minutes for a week.
90    pub window_minutes: Option<u64>,
91    /// Unix epoch seconds at which it resets.
92    pub resets_at: Option<i64>,
93}
94
95/// A pay-as-you-go balance.
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[non_exhaustive]
98pub struct Credits {
99    /// Whether any credits are available.
100    pub has_credits: bool,
101    /// Whether the account is uncapped.
102    pub unlimited: bool,
103    /// The balance as the provider wrote it. Kept as text because it arrives as
104    /// a string and a decimal balance must not be rounded through a float on
105    /// its way to a display.
106    pub balance: Option<String>,
107}
108
109/// All-time counters.
110#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
111#[non_exhaustive]
112pub struct Lifetime {
113    /// Tokens used since the account began.
114    pub tokens: Option<u64>,
115    /// The busiest single day, in tokens.
116    pub peak_daily_tokens: Option<u64>,
117    /// The longest single turn, in seconds.
118    pub longest_turn_secs: Option<u64>,
119    /// Consecutive days of use up to now.
120    pub current_streak_days: Option<u64>,
121    /// The longest such run ever.
122    pub longest_streak_days: Option<u64>,
123}
124
125/// One day's total.
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127#[non_exhaustive]
128pub struct DailyUsage {
129    /// ISO date, as the provider wrote it.
130    pub date: String,
131    /// Tokens used that day.
132    pub tokens: Option<u64>,
133}
134
135impl Agent {
136    /// Whether this agent can report account-wide usage without a terminal.
137    ///
138    /// Worth asking before building a quota panel, since two of the three
139    /// cannot and no amount of retrying changes that.
140    #[must_use]
141    pub fn reports_account_usage(self) -> bool {
142        matches!(self, Agent::Codex)
143    }
144
145    /// Ask the agent what the account has spent and what remains.
146    ///
147    /// # Errors
148    /// [`Error::Unsupported`] where the agent has no headless way to answer,
149    /// which today is Claude and Copilot; check
150    /// [`Agent::reports_account_usage`] first to avoid the round trip.
151    /// [`Error::NotInstalled`] if the binary is missing, [`Error::Spawn`] if it
152    /// cannot be run, [`Error::Timeout`] if it does not reply,
153    /// [`Error::AgentError`] if it replies with a refusal, and
154    /// [`Error::Parse`] if the reply is not the expected shape.
155    pub async fn account_usage(self) -> Result<AccountUsage> {
156        match self {
157            Agent::Codex => codex_account_usage(self.bin()).await,
158            // Deliberately an error rather than a half-answer assembled from a
159            // past run's rate-limit event: that would be neither current nor
160            // account-wide, and would read as though it were both.
161            Agent::Claude | Agent::Copilot => Err(Error::Unsupported {
162                agent: self,
163                what: "reporting account usage without a terminal",
164            }),
165        }
166    }
167}
168
169/// Query `codex app-server`, an experimental JSON-RPC interface over stdio.
170///
171/// Experimental is the operative word: the method names below are not covered
172/// by the live suite's version check, and Codex may rename them. A rename
173/// surfaces as [`Error::AgentError`] carrying the server's own complaint, which
174/// names the method, rather than as silence.
175///
176/// Verified against codex-cli 0.145.0 on 2026-07-29.
177async 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        // Silenced rather than captured: the server logs progress here and none
183        // of it belongs in an error about usage.
184        .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    // The server runs until its stdin closes, so it is killed either way rather
211    // than left behind holding a pipe.
212    let _ = child.kill().await;
213    result
214}
215
216/// Drive the three requests and collect their replies.
217async 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    // `initialize` first: the server rejects everything else until it has been
229    // told who is calling.
230    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        // A stream that ends before every reply arrives leaves whatever was
268        // collected in place rather than discarding it.
269        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            // A notification, not a reply. The server emits several.
280            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
318/// One JSON-RPC request, newline delimited as the server expects.
319fn 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
326/// Read `account/read`.
327fn 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
337/// Read `account/rateLimits/read`.
338fn read_limits(usage: &mut AccountUsage, result: &Value) {
339    let Some(limits) = result.get("rateLimits") else {
340        return;
341    };
342    // `primary` and `secondary` are separate keys rather than a list, and
343    // `secondary` is null on plans that have only one window.
344    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    // `account/read` is the better source, but a plan named here still beats
373    // nothing if that reply was the one that went missing.
374    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
382/// Read `account/usage/read`.
383fn 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    /// Trimmed from a real `account/rateLimits/read` reply (codex-cli 0.145.0).
412    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        // A week, which a host can render however it likes.
426        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    /// A decimal balance must reach a display without passing through a float.
432    #[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    /// `secondary` is a key rather than a list entry, and is null on plans with
443    /// one window. A null must not become an empty window.
444    #[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    /// The capability is answerable without spawning anything, so a host can
472    /// decide whether to build the panel at all.
473    #[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}