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