Commit Diff


commit - 1a10940bb412ceb68b371b20795b8b5d24f3cb81
commit + 263a717a36dbe0de46c56e14ce13b4360c82ed16
blob - e2cb50c19595d3cdee57e6e9dff06174eeef89c0
blob + 48fb9572cebdccd79bb533df8901ccc060f920bd
--- Cargo.lock
+++ Cargo.lock
@@ -13,7 +13,7 @@ dependencies = [
 
 [[package]]
 name = "alpaca"
-version = "0.7.1"
+version = "0.7.2"
 dependencies = [
  "anyhow",
  "assert_cmd",
blob - 1fd96a6f875c256600daa97f3f43d7e8414c6d4d
blob + 018a5c92c417e149cb77eb8c435ada23ff43d923
--- Cargo.toml
+++ Cargo.toml
@@ -1,7 +1,7 @@
 [package]
 name = "alpaca"
 authors = ["leoshimo", "mtmn"]
-version = "0.7.1"
+version = "0.7.2"
 edition = "2024"
 description = "Unix native interface for LLMs"
 repository = "https://github.com/leoshimo/cogni"
blob - fd5335427b07641e6eef1efb0061908522aa312c
blob + 8f99dccfb1a3e966e6ef4cc52f27b981b6cb5c91
--- README.md
+++ README.md
@@ -249,6 +249,7 @@ $ alpaca quota -p synthetic    # Synthetic
 $ alpaca quota -p anthropic    # Claude Code
 $ alpaca quota -p openai       # OpenAI organization usage
 $ alpaca quota -p deepinfra    # DeepInfra
+$ alpaca quota -p codex        # OpenAI Codex
 ```
 
 A provider with no usable credential, or a failed request or response, is
@@ -264,6 +265,7 @@ Each provider has its own default credential and base 
 | `anthropic` | none | `https://api.anthropic.com` |
 | `openai` | none | `https://api.openai.com` |
 | `deepinfra` | `$DEEPINFRA_API_KEY` | `https://api.deepinfra.com` |
+| `codex` | the OAuth token in `$CODEX_HOME/auth.json` | `https://chatgpt.com` |
 
 Every provider also accepts `--apikey`, an explicit `--profile`, or a table
 named after the provider, such as `[anthropic]`. Anthropic and OpenAI have no
@@ -281,9 +283,18 @@ account. It needs an Admin API key with the `api.usage
 (`sk-proj-...`) gets a 403.
 
 DeepInfra bills per token or per second, with no fixed quota, so alpaca
-prints no window for it. Like OpenAI, it instead prints the spend for the
-current month as a note, and billed units per model as a table.
+prints no window for it. It instead prints the remaining credit as a note.
+A negative balance is funds ready to spend, and a positive one is money
+owed.
 
+The Codex provider reports your ChatGPT plan usage, not organization spend.
+alpaca reads the OAuth token the Codex CLI stores in `$CODEX_HOME/auth.json`
+(default `~/.codex/auth.json`), so sign in once with `codex` and the report
+works; `--apikey` replaces that token. The endpoint serves the Codex CLI and
+the ChatGPT client, so it is not a documented public API and may change
+without notice. alpaca prints the 5 hour and weekly windows with their reset
+times, the plan name, and the credits balance when the plan has one.
+
 The top-level `base_url` configures the chat endpoint only, so `alpaca
 quota` never uses it: each provider already has the correct endpoint built
 in. A table named after a provider is picked up automatically, but only for
@@ -302,8 +313,8 @@ never sending the top-level `apikey` to them all.
 Options:
 
 - `-p, --provider <PROVIDER>`: one of `synthetic`, `anthropic`, `openai`,
-  `deepinfra`. Without it, all providers are reported and the unreachable ones
-  are omitted
+  `deepinfra`, `codex`. Without it, all providers are reported and the
+  unreachable ones are omitted
 - `-T, --timeout <SECS>`: request timeout in seconds
 - `--apikey <KEY>`: API key or OAuth token, replacing the provider default
 - `--base-url <URL>`: base URL, replacing the provider default
blob - e83420e4b85b563261db9175c2f1ece6397ad7dd
blob + 1fcef493555fb0761fe9d41cfe39843fa6c0061a
--- man/alpaca-quota.1.scd
+++ man/alpaca-quota.1.scd
@@ -47,11 +47,22 @@ all providers.
 	limit, so alpaca prints no window for it: instead, the current UTC
 	month's spend as a note, and billed tokens per model as a table.
 
+*codex*
+	OpenAI Codex. Takes the bearer token from the OAuth file the Codex CLI
+	stores in *$CODEX_HOME/auth.json*, defaulting to *~/.codex/auth.json*;
+	*--apikey* replaces it. Base URL *https://chatgpt.com*. The endpoint
+	serves the Codex CLI and the ChatGPT client, so it is not a documented
+	public API and may change without notice. It needs a ChatGPT sign-in,
+	not an API key. Prints the 5 hour and weekly usage windows with their
+	reset times, the plan name, and the credits balance when the plan has
+	one.
+
 *deepinfra*
 	DeepInfra. Takes the key from *DEEPINFRA_API_KEY*. Base URL
 	*https://api.deepinfra.com*. DeepInfra bills per token or per second, with
 	no fixed quota, so alpaca prints no window for it. Instead, it prints the
-	current month's spend as a note, and billed units per model as a table.
+	remaining credit from the billing checklist as a note. A negative balance
+	is funds ready to spend, and a positive one is money owed.
 
 The *anthropic* endpoint is not a documented public API, so it may change
 without notice. It expects the short-lived OAuth token the Claude Code CLI
@@ -61,8 +72,9 @@ again.
 # OPTIONS
 
 *-p*, *--provider* _provider_
-	One of *synthetic*, *anthropic*, *openai* or *deepinfra*. Without it,
-	alpaca reports all providers and omits the ones it cannot reach.
+	One of *synthetic*, *anthropic*, *openai*, *deepinfra* or *codex*.
+	Without it, alpaca reports all providers and omits the ones it cannot
+	reach.
 
 *-T*, *--timeout* _secs_
 	Request timeout in seconds. Defaults to *ALPACA_TIMEOUT*, else 60.
blob - 6df41156085f43078e24f482ebd0c24afc591aae
blob + 3579d23ce7578ddf48e206473c823c33d4970c9f
--- src/cli.rs
+++ src/cli.rs
@@ -818,6 +818,7 @@ impl ValueEnum for Provider {
             Self::Anthropic,
             Self::OpenAI,
             Self::DeepInfra,
+            Self::Codex,
         ]
     }
 
@@ -827,6 +828,7 @@ impl ValueEnum for Provider {
             Self::Anthropic => "anthropic",
             Self::OpenAI => "openai",
             Self::DeepInfra => "deepinfra",
+            Self::Codex => "codex",
         }))
     }
 }
blob - 20b0a13b09b09c7dbc840633541993bfeba410a6
blob + ad3e91cdd1979d4083e6e59b89ec90c7e2e3dcf3
--- src/exec/quota.rs
+++ src/exec/quota.rs
@@ -74,17 +74,19 @@ async fn exec_all_providers(args: &QuotaArgs) -> Resul
         bail!("--raw prints one provider's response body; choose the provider with --provider");
     }
 
-    let (synthetic, anthropic, openai, deepinfra) = tokio::join!(
+    let (synthetic, anthropic, openai, deepinfra, codex) = tokio::join!(
         fetch_view(args, Provider::Synthetic),
         fetch_view(args, Provider::Anthropic),
         fetch_view(args, Provider::OpenAI),
         fetch_view(args, Provider::DeepInfra),
+        fetch_view(args, Provider::Codex),
     );
     let reports = [
         (Provider::Synthetic, synthetic),
         (Provider::Anthropic, anthropic),
         (Provider::OpenAI, openai),
         (Provider::DeepInfra, deepinfra),
+        (Provider::Codex, codex),
     ];
 
     let mut stdout = io::stdout();
@@ -146,7 +148,7 @@ async fn fetch(args: &QuotaArgs, provider: Provider) -
     client_for(args, provider)?
         .fetch()
         .await
-        .with_context(|| format!("failed to fetch {provider} usage"))
+        .with_context(|| format!("failed to fetch {provider} quota"))
 }
 
 /// Fetch the provider's extra body, when it has one beyond usage
blob - 720aff7ada6a318d1d13c1f51fe88239a7484b22
blob + b131913c165ac995cf6fb1386cdedbce068aa660
--- src/usage/deepinfra.rs
+++ src/usage/deepinfra.rs
@@ -1,253 +1,97 @@
-//! `DeepInfra` usage
+//! `DeepInfra` credit
 //!
-//! `DeepInfra` bills per token or per second with no fixed quota, so there is no
-//! window to show a used share of. The billing endpoint instead reports the
-//! current month's spend, and a per-model breakdown of billed units, shown as
-//! a request table like the other providers. Remaining credit is reported on
-//! a separate billing checklist endpoint, so it is fetched independently and
-//! joins the month's spend in the trailing note.
+//! `DeepInfra` bills per token or per second with no fixed quota, so there is
+//! no window to show a used share of. The billing checklist instead reports
+//! the account balance: a negative `stripe_balance` is funds ready to spend,
+//! and a positive one is money owed.
 
 use serde::Deserialize;
 
-use super::{ModelCount, Table, View};
+use super::View;
 use crate::Error;
 
-#[derive(Debug, Default, Deserialize)]
-struct ModelMeta {
-    #[serde(default)]
-    model_name: String,
-}
-
-#[derive(Debug, Default, Deserialize)]
-struct UsageItem {
-    #[serde(default)]
-    model: ModelMeta,
-    /// Billed seconds or tokens
-    #[serde(default)]
-    units: u64,
-}
-
-#[derive(Debug, Default, Deserialize)]
-struct UsageMonth {
-    #[serde(default)]
-    period: String,
-    /// Total cost for the period, in cents
-    #[serde(default)]
-    total_cost: i64,
-    #[serde(default)]
-    items: Vec<UsageItem>,
-}
-
-#[derive(Debug, Deserialize)]
-struct Usage {
-    #[serde(default)]
-    months: Vec<UsageMonth>,
-}
-
 /// The billing checklist's account balance
-///
-/// A negative balance is funds ready to spend; a positive one is money owed.
 #[derive(Debug, Default, Deserialize)]
 struct Checklist {
     #[serde(default)]
     stripe_balance: f64,
 }
 
-/// Normalise the `DeepInfra` billing usage response
+/// Normalise the `DeepInfra` billing checklist response
 ///
-/// `credit` is the body of [`Provider::credit_path`](super::Provider::credit_path),
-/// when the caller fetched one; it only ever adds to the trailing note, so a
-/// missing or unparseable credit body is silently left out rather than
-/// failing the whole view.
-///
 /// # Errors
 ///
-/// Returns an error if `body` is not a `DeepInfra` usage response.
-pub fn view(body: &[u8], credit: Option<&[u8]>) -> Result<View, Error> {
-    let usage: Usage = super::parse_usage(body, "deepinfra", &["months", "initial_month"])?;
+/// Returns an error if `body` is not a `DeepInfra` checklist response.
+pub fn view(body: &[u8]) -> Result<View, Error> {
+    let value = super::parse_json(body)?;
+    if value.get("stripe_balance").is_none() {
+        return Err(Error::UnexpectedResponse(
+            "response contains no deepinfra credit fields".to_string(),
+        ));
+    }
+    let checklist: Checklist = super::from_json(value)?;
 
-    let month = usage.months.first();
-    let items = month.map_or([].as_slice(), |month| month.items.as_slice());
+    let balance = checklist.stripe_balance;
+    let note = Some(if balance < 0.0 {
+        format!("{:.2} USD credit left", -balance)
+    } else if balance > 0.0 {
+        format!("owes {balance:.2} USD")
+    } else {
+        "0.00 USD credit left".to_string()
+    });
 
-    let spend = month.map(|month| format!("total {}", cents(month.total_cost)));
-    let note = match (spend, credit.and_then(credit_note)) {
-        (Some(spend), Some(credit)) => Some(format!("{spend}, {credit}")),
-        (spend, credit) => spend.or(credit),
-    };
-
     Ok(View {
-        title: "deepinfra usage".to_string(),
-        subtitle: month
-            .map(|month| month.period.clone())
-            .filter(|period| !period.is_empty()),
+        title: "deepinfra credit".to_string(),
+        subtitle: None,
         note,
         windows: vec![],
-        tables: vec![Table {
-            heading: "models".to_string(),
-            unit: "units",
-            rows: by_model(items),
-        }],
+        tables: vec![],
     })
 }
 
-/// Format a cent amount as a dollar figure
-///
-/// `total_cost` can go negative, for example when a discount outweighs a
-/// month's usage, so the sign is carried separately from the magnitude:
-/// integer division truncates towards zero, which would otherwise drop the
-/// sign whenever the whole-dollar part is zero (`-50` cents would read as
-/// `0.50` instead of `-0.50`).
-fn cents(cents: i64) -> String {
-    let sign = if cents < 0 { "-" } else { "" };
-    let whole = cents.abs() / 100;
-    let fraction = cents.abs() % 100;
-    format!("{sign}{whole}.{fraction:02} USD")
-}
-
-/// Describe the remaining credit from a billing checklist response
-///
-/// Returns `None` when `body` is not a checklist response, so a broken or
-/// unexpected credit body never blocks the usage report.
-fn credit_note(body: &[u8]) -> Option<String> {
-    let checklist: Checklist = serde_json::from_slice(body).ok()?;
-    Some(if checklist.stripe_balance <= 0.0 {
-        format!("{:.2} USD credit left", -checklist.stripe_balance)
-    } else {
-        format!("owes {:.2} USD", checklist.stripe_balance)
-    })
-}
-
-/// Billed units per model, busiest first
-///
-/// A model can appear in several items when its pricing changed mid-month, so
-/// units are summed per model name before sorting.
-fn by_model(items: &[UsageItem]) -> Vec<ModelCount> {
-    let mut totals: Vec<ModelCount> = Vec::new();
-    for item in items {
-        if item.model.model_name.is_empty() {
-            continue;
-        }
-        match totals
-            .iter_mut()
-            .find(|count| count.name == item.model.model_name)
-        {
-            Some(count) => count.requests += item.units,
-            None => totals.push(ModelCount {
-                name: item.model.model_name.clone(),
-                requests: item.units,
-            }),
-        }
-    }
-    totals.sort_by_key(|count| std::cmp::Reverse(count.requests));
-    totals
-}
-
 #[cfg(test)]
 mod test {
     use super::*;
     use anyhow::Result;
 
     #[test]
-    fn reads_the_current_months_total_cost_and_models() -> Result<()> {
-        let body = br#"{
-            "months": [{"period": "2026.09", "interval": {}, "total_cost": 1234, "items": [
-                {"model": {"model_name": "meta-llama/Llama-3.1-70B"}, "units": 100, "rate": 0, "cost": 900, "pricing_type": "token", "interval": {}},
-                {"model": {"model_name": "meta-llama/Llama-3.1-70B"}, "units": 50, "rate": 0, "cost": 100, "pricing_type": "token", "interval": {}},
-                {"model": {"model_name": "Qwen/Qwen2.5-7B"}, "units": 80, "rate": 0, "cost": 234, "pricing_type": "token", "interval": {}}
-            ]}],
-            "initial_month": "2024.01"
-        }"#;
-
-        let view = view(body, None)?;
-
-        assert_eq!(view.title, "deepinfra usage");
-        assert_eq!(view.subtitle.as_deref(), Some("2026.09"));
-        assert_eq!(view.note.as_deref(), Some("total 12.34 USD"));
-        assert!(view.windows.is_empty());
-        assert_eq!(view.tables[0].heading, "models");
-        assert_eq!(view.tables[0].unit, "units");
-        assert_eq!(
-            view.tables[0].rows,
-            vec![
-                ModelCount {
-                    name: "meta-llama/Llama-3.1-70B".to_string(),
-                    requests: 150
-                },
-                ModelCount {
-                    name: "Qwen/Qwen2.5-7B".to_string(),
-                    requests: 80
-                }
-            ],
-            "units for the same model are summed, then sorted busiest first"
-        );
-        Ok(())
-    }
-
-    #[test]
-    fn omits_note_and_subtitle_without_months() -> Result<()> {
-        let view = view(br#"{"months": [], "initial_month": "2024.01"}"#, None)?;
-
-        assert_eq!(view.note, None);
-        assert_eq!(view.subtitle, None);
-        assert!(view.tables[0].rows.is_empty());
-        Ok(())
-    }
-
-    #[test]
-    fn rejects_non_usage_bodies() {
-        assert!(view(b"not json", None).is_err());
-        assert!(view(b"{}", None).is_err());
-    }
-
-    #[test]
-    fn a_discount_can_take_the_total_negative() -> Result<()> {
-        let view = view(
-            br#"{
-            "months": [{"period": "2026.09", "interval": {}, "total_cost": -50, "items": []}],
-            "initial_month": "2024.01"
-        }"#,
-            None,
-        )?;
-
-        assert_eq!(
-            view.note.as_deref(),
-            Some("total -0.50 USD"),
-            "the sign must not be lost when the whole-dollar part is zero"
-        );
-        Ok(())
-    }
-
-    #[test]
     fn a_negative_balance_is_credit_left() -> Result<()> {
-        let body = br#"{"months": [{"period": "2026.09", "total_cost": 1234, "items": []}]}"#;
-        let credit = br#"{"stripe_balance": -50.0}"#;
+        let view = view(br#"{"stripe_balance": -50.0, "email": false}"#)?;
 
-        let view = view(body, Some(credit))?;
-
-        assert_eq!(
-            view.note.as_deref(),
-            Some("total 12.34 USD, 50.00 USD credit left")
-        );
+        assert_eq!(view.title, "deepinfra credit");
+        assert_eq!(view.subtitle, None);
+        assert_eq!(view.note.as_deref(), Some("50.00 USD credit left"));
+        assert!(view.windows.is_empty());
+        assert!(view.tables.is_empty());
         Ok(())
     }
 
     #[test]
     fn a_positive_balance_is_money_owed() -> Result<()> {
-        let credit = br#"{"stripe_balance": 12.5}"#;
+        let view = view(br#"{"stripe_balance": 12.5}"#)?;
 
-        let view = view(br#"{"months": []}"#, Some(credit))?;
-
         assert_eq!(view.note.as_deref(), Some("owes 12.50 USD"));
         Ok(())
     }
 
     #[test]
-    fn an_unparseable_credit_body_is_silently_dropped() -> Result<()> {
-        let body = br#"{"months": [{"period": "2026.09", "total_cost": 1234, "items": []}]}"#;
+    fn a_zero_balance_reads_as_no_credit() -> Result<()> {
+        let view = view(br#"{"stripe_balance": 0}"#)?;
 
-        let view = view(body, Some(b"not json"))?;
-
-        assert_eq!(view.note.as_deref(), Some("total 12.34 USD"));
+        assert_eq!(view.note.as_deref(), Some("0.00 USD credit left"));
         Ok(())
     }
+
+    #[test]
+    fn rejects_non_checklist_bodies() {
+        assert!(view(b"not json").is_err());
+        assert!(
+            view(b"{}").is_err(),
+            "a body without a balance is not a checklist"
+        );
+        assert!(
+            view(br#"{"months": []}"#).is_err(),
+            "the usage response is not credit"
+        );
+    }
 }
blob - /dev/null
blob + a83b5cf000a5ae7f86a8209a6fc6de580580114c (mode 644)
--- /dev/null
+++ src/usage/codex.rs
@@ -0,0 +1,179 @@
+//! `OpenAI` Codex usage
+//!
+//! The endpoint serves the Codex CLI and the `ChatGPT` client. It is not a
+//! documented public API, so it may change without notice.
+
+use chrono::{DateTime, TimeZone, Utc};
+use serde::Deserialize;
+
+use super::{View, Window};
+use crate::Error;
+
+/// One rolling usage window, reported in percent
+#[derive(Debug, Default, Deserialize)]
+struct RateLimitWindow {
+    #[serde(default)]
+    used_percent: f64,
+    /// Unix timestamp, in seconds, of when the window resets
+    #[serde(default)]
+    reset_at: Option<i64>,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct RateLimit {
+    #[serde(default)]
+    primary_window: Option<RateLimitWindow>,
+    #[serde(default)]
+    secondary_window: Option<RateLimitWindow>,
+}
+
+/// The `OpenAI` credits attached to the plan
+#[derive(Debug, Default, Deserialize)]
+struct Credits {
+    #[serde(default, rename = "has_credits")]
+    has: bool,
+    #[serde(default)]
+    unlimited: bool,
+    #[serde(default)]
+    balance: Option<String>,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Usage {
+    #[serde(default)]
+    plan_type: Option<String>,
+    #[serde(default)]
+    rate_limit: Option<RateLimit>,
+    #[serde(default)]
+    credits: Option<Credits>,
+}
+
+/// Normalise the Codex usage response
+///
+/// The primary window covers 5 hours and the secondary one a week, matching
+/// how the Codex CLI presents them.
+///
+/// # Errors
+///
+/// Returns an error if the body is not a Codex usage response.
+pub fn view(body: &[u8]) -> Result<View, Error> {
+    let usage: Usage = super::parse_usage(body, "codex", &["plan_type", "rate_limit", "credits"])?;
+
+    let windows = usage
+        .rate_limit
+        .map(|limits| {
+            [
+                ("5 hours", limits.primary_window),
+                ("week", limits.secondary_window),
+            ]
+            .into_iter()
+            .filter_map(|(name, window)| {
+                window.map(|window| {
+                    Window::new(name, window.used_percent / 100.0, reset_at(window.reset_at))
+                })
+            })
+            .collect()
+        })
+        .unwrap_or_default();
+
+    Ok(View {
+        title: "codex usage".to_string(),
+        subtitle: usage.plan_type.filter(|plan| !plan.is_empty()),
+        note: usage.credits.as_ref().and_then(credits_note),
+        windows,
+        tables: vec![],
+    })
+}
+
+/// A window reset given as a Unix timestamp in seconds
+fn reset_at(reset_at: Option<i64>) -> Option<DateTime<Utc>> {
+    reset_at.and_then(|at| Utc.timestamp_opt(at, 0).single())
+}
+
+/// Describe the credits balance, when there is one to show
+///
+/// A plan with no credits, or with a zero balance, says nothing: the windows
+/// already carry the interesting number.
+fn credits_note(credits: &Credits) -> Option<String> {
+    if credits.unlimited {
+        return Some("unlimited credits".to_string());
+    }
+    if !credits.has {
+        return None;
+    }
+
+    let balance = credits.balance.as_deref()?.trim();
+    let spent_out = balance.is_empty()
+        || balance
+            .trim_start_matches('$')
+            .parse::<f64>()
+            .is_ok_and(|amount| amount <= 0.0);
+    if spent_out {
+        return None;
+    }
+
+    Some(format!("credits {balance}"))
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+    use anyhow::Result;
+
+    #[test]
+    fn parses_both_windows_and_the_plan() -> Result<()> {
+        let body = br#"{
+            "plan_type": "plus",
+            "rate_limit": {
+                "allowed": true,
+                "limit_reached": false,
+                "primary_window": {"used_percent": 0, "limit_window_seconds": 18000, "reset_at": 1788736280},
+                "secondary_window": {"used_percent": 71, "limit_window_seconds": 604800, "reset_at": 1789199648}
+            },
+            "credits": {"has_credits": false, "unlimited": false, "balance": "0"}
+        }"#;
+
+        let view = view(body)?;
+
+        assert_eq!(view.title, "codex usage");
+        assert_eq!(view.subtitle.as_deref(), Some("plus"));
+        assert_eq!(view.note, None);
+        assert_eq!(view.windows.len(), 2);
+        assert_eq!(view.windows[0].name, "5 hours");
+        assert_eq!(view.windows[0].used, 0.0);
+        assert_eq!(view.windows[1].name, "week");
+        assert_eq!(view.windows[1].used, 0.71);
+        assert_eq!(view.windows[1].resets_in_secs.is_some(), true);
+        Ok(())
+    }
+
+    #[test]
+    fn windows_are_optional() -> Result<()> {
+        let view = view(br#"{"plan_type": "free", "rate_limit": {}}"#)?;
+
+        assert!(view.windows.is_empty());
+        assert_eq!(view.subtitle.as_deref(), Some("free"));
+        Ok(())
+    }
+
+    #[test]
+    fn credits_are_shown_when_the_plan_has_them() -> Result<()> {
+        let with_balance =
+            view(br#"{"credits": {"has_credits": true, "unlimited": false, "balance": "$5.00"}}"#)?;
+        assert_eq!(with_balance.note.as_deref(), Some("credits $5.00"));
+
+        let unlimited = view(br#"{"credits": {"has_credits": true, "unlimited": true}}"#)?;
+        assert_eq!(unlimited.note.as_deref(), Some("unlimited credits"));
+
+        let zero = view(br#"{"credits": {"has_credits": true, "balance": "0"}}"#)?;
+        assert_eq!(zero.note, None, "a zero balance says nothing");
+
+        Ok(())
+    }
+
+    #[test]
+    fn rejects_non_usage_bodies() {
+        assert!(view(b"not json").is_err());
+        assert!(view(b"{}").is_err());
+    }
+}
blob - 08a2f64747138b04f37b36172c66d6f2004c50d1
blob + ac9c6c207d1e3d6d057ba7f736bee6b348107608
--- src/usage/mod.rs
+++ src/usage/mod.rs
@@ -5,6 +5,7 @@
 //! about quota windows and per-model request counts.
 
 pub mod anthropic;
+pub mod codex;
 pub mod deepinfra;
 pub mod openai;
 pub mod render;
@@ -20,6 +21,9 @@ use crate::Error;
 
 struct Credential {
     token: String,
+    /// `ChatGPT` account id, which the Codex usage endpoint wants alongside
+    /// the bearer token
+    account_id: Option<String>,
 }
 
 /// Largest response body read from a usage endpoint
@@ -34,6 +38,7 @@ pub enum Provider {
     Anthropic,
     OpenAI,
     DeepInfra,
+    Codex,
 }
 
 /// Provider usage, normalised for rendering
@@ -98,8 +103,7 @@ fn remaining_secs(at: DateTime<Utc>, now: DateTime<Utc
 /// Unix timestamp of the start of the current UTC month
 ///
 /// `OpenAI`'s organization usage and costs endpoints have no "current period"
-/// shortcut, unlike `DeepInfra`'s `from=current`: the caller must compute and
-/// pass a `start_time` itself.
+/// shortcut: the caller must compute and pass a `start_time` itself.
 fn month_start_unix() -> i64 {
     let now = Utc::now();
     Utc.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0)
@@ -109,11 +113,12 @@ fn month_start_unix() -> i64 {
 
 impl Provider {
     /// All providers, in report order
-    pub const ALL: [Provider; 4] = [
+    pub const ALL: [Provider; 5] = [
         Provider::Synthetic,
         Provider::Anthropic,
         Provider::OpenAI,
         Provider::DeepInfra,
+        Provider::Codex,
     ];
 
     /// Default base URL, honouring any environment override
@@ -124,6 +129,7 @@ impl Provider {
             Self::Anthropic => "https://api.anthropic.com".to_string(),
             Self::OpenAI => "https://api.openai.com".to_string(),
             Self::DeepInfra => "https://api.deepinfra.com".to_string(),
+            Self::Codex => "https://chatgpt.com".to_string(),
         }
     }
 
@@ -136,23 +142,25 @@ impl Provider {
             // The organization's total spend for the query window. `start_time`
             // and `limit` are appended per request, since they need today's date.
             Self::OpenAI => "/v1/organization/costs?bucket_width=1d",
-            // `from=current` selects the current billing month.
-            Self::DeepInfra => "/payment/usage?from=current",
+            // The billing checklist holds the account balance: a negative
+            // `stripe_balance` is funds ready to spend.
+            Self::DeepInfra => "/payment/checklist",
+            // Serves the Codex CLI and the ChatGPT client; not a documented
+            // public API.
+            Self::Codex => "/backend-api/wham/usage",
         }
     }
 
     /// Path of a second endpoint some providers need alongside the primary
-    /// one at [`path`](Self::path): `DeepInfra`'s remaining credit, reported
-    /// separately from usage, and `OpenAI`'s per-model token breakdown, which
-    /// its costs endpoint lacks
+    /// one at [`path`](Self::path): `OpenAI`'s per-model token breakdown,
+    /// which its costs endpoint lacks
     #[must_use]
     pub fn extra_path(self) -> Option<&'static str> {
         match self {
             Self::OpenAI => {
                 Some("/v1/organization/usage/completions?bucket_width=1d&group_by=model")
             }
-            Self::DeepInfra => Some("/payment/checklist"),
-            Self::Synthetic | Self::Anthropic => None,
+            Self::Synthetic | Self::Anthropic | Self::DeepInfra | Self::Codex => None,
         }
     }
 
@@ -161,7 +169,7 @@ impl Provider {
     pub fn bearer(self) -> bool {
         matches!(
             self,
-            Self::Synthetic | Self::Anthropic | Self::OpenAI | Self::DeepInfra
+            Self::Synthetic | Self::Anthropic | Self::OpenAI | Self::DeepInfra | Self::Codex
         )
     }
 
@@ -170,6 +178,7 @@ impl Provider {
     /// `Anthropic` and `OpenAI` have no default credential: `Anthropic`'s
     /// endpoint serves the Claude Code CLI, so alpaca only asks it with an
     /// explicit key, and `OpenAI`'s organization API always requires one.
+    /// `Codex` instead reads the OAuth token the Codex CLI stores.
     ///
     /// # Errors
     ///
@@ -180,11 +189,17 @@ impl Provider {
         let token = match self {
             Self::Synthetic => from_env("SYNTHETIC_API_KEY"),
             Self::DeepInfra => from_env("DEEPINFRA_API_KEY"),
+            Self::Codex => {
+                return codex_credential();
+            }
             Self::Anthropic | Self::OpenAI => None,
         };
 
         token
-            .map(|token| Credential { token })
+            .map(|token| Credential {
+                token,
+                account_id: None,
+            })
             .ok_or(Error::NoCredentials { provider: self })
     }
 
@@ -201,7 +216,8 @@ impl Provider {
             Self::Synthetic => synthetic::view(body),
             Self::Anthropic => anthropic::view(body),
             Self::OpenAI => openai::view(body, extra),
-            Self::DeepInfra => deepinfra::view(body, extra),
+            Self::DeepInfra => deepinfra::view(body),
+            Self::Codex => codex::view(body),
         }
     }
 
@@ -269,6 +285,7 @@ impl fmt::Display for Provider {
             Self::Anthropic => "anthropic",
             Self::OpenAI => "openai",
             Self::DeepInfra => "deepinfra",
+            Self::Codex => "codex",
         };
         f.write_str(name)
     }
@@ -278,12 +295,54 @@ fn user_agent() -> String {
     format!("alpaca/{}", env!("CARGO_PKG_VERSION"))
 }
 
+/// The Codex credential from the OAuth token the Codex CLI stores
+///
+/// The CLI keeps its tokens in `$CODEX_HOME/auth.json`, defaulting to
+/// `~/.codex/auth.json`. The `ChatGPT` account id rides along, since the usage
+/// endpoint wants it next to the bearer token.
+///
+/// # Errors
+///
+/// Returns [`Error::NoCredentials`] when the file is missing, unreadable or
+/// holds no access token.
+fn codex_credential() -> Result<Credential, Error> {
+    let home = match std::env::var_os("CODEX_HOME") {
+        Some(home) if !home.is_empty() => std::path::PathBuf::from(home),
+        _ => crate::home_dir()?.join(".codex"),
+    };
+
+    let missing = || Error::NoCredentials {
+        provider: Provider::Codex,
+    };
+    let auth: serde_json::Value = serde_json::from_str(
+        &std::fs::read_to_string(home.join("auth.json")).map_err(|_| missing())?,
+    )
+    .map_err(|_| missing())?;
+    let tokens = auth.get("tokens").ok_or_else(missing)?;
+
+    let token = tokens
+        .get("access_token")
+        .and_then(serde_json::Value::as_str)
+        .filter(|token| !token.is_empty())
+        .ok_or_else(missing)?;
+    let account_id = tokens
+        .get("account_id")
+        .and_then(serde_json::Value::as_str)
+        .map(str::to_string);
+
+    Ok(Credential {
+        token: token.to_string(),
+        account_id,
+    })
+}
+
 /// Client for provider usage endpoints
 pub struct Client {
     http: reqwest::Client,
     provider: Provider,
     base_url: String,
     api_key: String,
+    account_id: Option<String>,
     timeout: Duration,
 }
 
@@ -302,7 +361,10 @@ impl Client {
         timeout: Duration,
     ) -> Result<Self, Error> {
         let credential = match api_key {
-            Some(token) => Credential { token },
+            Some(token) => Credential {
+                token,
+                account_id: None,
+            },
             None => provider.credential()?,
         };
 
@@ -311,6 +373,7 @@ impl Client {
             provider,
             base_url: base_url.unwrap_or_else(|| provider.base_url()),
             api_key: credential.token,
+            account_id: credential.account_id,
             timeout,
         })
     }
@@ -353,6 +416,10 @@ impl Client {
             request = request.header("anthropic-beta", "oauth-2025-04-20");
         }
 
+        if let Some(account_id) = &self.account_id {
+            request = request.header("ChatGPT-Account-Id", account_id);
+        }
+
         if self.provider == Provider::OpenAI {
             // The organization usage and costs endpoints require an explicit
             // start of the query window; there is no "current period" shortcut
@@ -374,9 +441,16 @@ impl Client {
         if status.is_success() {
             Ok(body)
         } else {
+            let mut message = String::from_utf8_lossy(&body).trim().to_string();
+            if self.provider == Provider::OpenAI && status.as_u16() == 403 {
+                message.push_str(
+                    " (create an Admin API key with the api.usage.read scope at \
+                     platform.openai.com/settings/organization/admin-keys)",
+                );
+            }
             Err(Error::HttpStatus {
                 status: status.as_u16(),
-                message: String::from_utf8_lossy(&body).trim().to_string(),
+                message,
             })
         }
     }
blob - fc06876bf3d09e978bd2a9da06b2c4e0f186b788
blob + c3722c73aa67ed5226049443bd10b69186d5a8a5
--- tests/quota.rs
+++ tests/quota.rs
@@ -2,6 +2,7 @@
 
 use assert_cmd::Command;
 use predicates::prelude::*;
+use std::fs;
 use std::sync::LazyLock;
 use tempfile::TempDir;
 
@@ -22,6 +23,9 @@ fn ok_body() -> &'static str {
 fn alpaca() -> Command {
     let mut cmd = Command::cargo_bin("alpaca").unwrap();
     cmd.env("XDG_CONFIG_HOME", ISOLATED_CONFIG_HOME.path());
+    // The Codex provider reads the CLI's auth file by default; point it at an
+    // empty home so tests never touch the real one.
+    cmd.env("CODEX_HOME", ISOLATED_CONFIG_HOME.path());
     cmd
 }
 
@@ -148,7 +152,7 @@ fn quota_reports_provider_errors() {
     mock.assert();
 
     cmd.failure().stderr(
-        predicate::str::contains("failed to fetch synthetic usage")
+        predicate::str::contains("failed to fetch synthetic quota")
             .and(predicate::str::contains("unauthorized")),
     );
 }
@@ -202,10 +206,16 @@ fn mock_all_providers(server: &mut mockito::Server) {
         .with_body(r#"{"object":"page","data":[],"has_more":false,"next_page":null}"#)
         .create();
     server
-        .mock("GET", "/payment/usage?from=current")
+        .mock("GET", "/payment/checklist")
+        .with_body(r#"{"stripe_balance": -50.0}"#)
+        .create();
+    server
+        .mock("GET", "/backend-api/wham/usage")
         .with_body(
-            r#"{"months": [{"period": "2026.09", "interval": {}, "total_cost": 1234, "items": []}],
-                "initial_month": "2024.01"}"#,
+            r#"{"plan_type": "plus",
+                "rate_limit": {"primary_window": {"used_percent": 10, "limit_window_seconds": 18000, "reset_at": 1788736280},
+                               "secondary_window": {"used_percent": 71, "limit_window_seconds": 604800, "reset_at": 1789199648}},
+                "credits": {"has_credits": false, "unlimited": false, "balance": "0"}}"#,
         )
         .create();
 }
@@ -222,7 +232,8 @@ fn an_all_providers_report_draws_each_provider_in_one_
             predicate::str::contains("synthetic usage")
                 .and(predicate::str::contains("anthropic usage"))
                 .and(predicate::str::contains("openai usage"))
-                .and(predicate::str::contains("deepinfra usage")),
+                .and(predicate::str::contains("deepinfra credit"))
+                .and(predicate::str::contains("codex usage")),
         );
 }
 
@@ -368,58 +379,15 @@ fn anthropic_quota_sends_oauth_beta_header() {
 }
 
 #[test]
-fn deepinfra_quota_reports_the_current_months_spend() {
-    let mut server = mockito::Server::new();
-    let mock = server
-        .mock("GET", "/payment/usage?from=current")
-        .match_header("authorization", "Bearer KEY")
-        .with_body(
-            r#"{"months": [{"period": "2026.09", "interval": {}, "total_cost": 1234, "items": [
-                {"model": {"model_name": "meta-llama/Llama-3.1-70B"}, "units": 100, "rate": 0, "cost": 1234, "pricing_type": "token", "interval": {}}
-            ]}],
-                "initial_month": "2024.01"}"#,
-        )
-        .create();
-
-    alpaca()
-        .args([
-            "quota",
-            "-p",
-            "deepinfra",
-            "--apikey",
-            "KEY",
-            "--base-url",
-            &server.url(),
-        ])
-        .assert()
-        .success()
-        .stdout(
-            predicate::str::contains("deepinfra usage")
-                .and(predicate::str::contains("2026.09"))
-                .and(predicate::str::contains("total 12.34 USD"))
-                .and(predicate::str::contains("meta-llama/Llama-3.1-70B"))
-                .and(predicate::str::contains("units")),
-        );
-
-    mock.assert();
-}
-
-#[test]
 fn deepinfra_quota_reports_remaining_credit() {
     let mut server = mockito::Server::new();
-    let usage = server
-        .mock("GET", "/payment/usage?from=current")
-        .with_body(
-            r#"{"months": [{"period": "2026.09", "total_cost": 1234, "items": []}],
-                "initial_month": "2024.01"}"#,
-        )
-        .create();
     let checklist = server
         .mock("GET", "/payment/checklist")
         .match_header("authorization", "Bearer KEY")
         .with_body(r#"{"stripe_balance": -50.0}"#)
         .create();
 
+    // Only the checklist is fetched: the usage endpoint is never asked.
     alpaca()
         .args([
             "quota",
@@ -432,13 +400,91 @@ fn deepinfra_quota_reports_remaining_credit() {
         ])
         .assert()
         .success()
-        .stdout(predicate::str::contains("50.00 USD credit left"));
+        .stdout(
+            predicate::str::contains("deepinfra credit")
+                .and(predicate::str::contains("50.00 USD credit left")),
+        );
 
-    usage.assert();
     checklist.assert();
 }
 
 #[test]
+fn codex_quota_reports_both_windows_and_the_plan() {
+    let mut server = mockito::Server::new();
+    let mock = server
+        .mock("GET", "/backend-api/wham/usage")
+        .match_header("authorization", "Bearer KEY")
+        .with_body(
+            r#"{"plan_type": "plus",
+                "rate_limit": {"primary_window": {"used_percent": 10, "limit_window_seconds": 18000, "reset_at": 1788736280},
+                               "secondary_window": {"used_percent": 71, "limit_window_seconds": 604800, "reset_at": 1789199648}},
+                "credits": {"has_credits": false, "unlimited": false, "balance": "0"}}"#,
+        )
+        .create();
+
+    alpaca()
+        .args([
+            "quota",
+            "-p",
+            "codex",
+            "--apikey",
+            "KEY",
+            "--base-url",
+            &server.url(),
+        ])
+        .assert()
+        .success()
+        .stdout(
+            predicate::str::contains("codex usage")
+                .and(predicate::str::contains("plus"))
+                .and(predicate::str::contains("5 hours"))
+                .and(predicate::str::contains("week")),
+        );
+
+    mock.assert();
+}
+
+#[test]
+fn codex_reads_the_token_the_cli_stored() {
+    let mut server = mockito::Server::new();
+    let mock = server
+        .mock("GET", "/backend-api/wham/usage")
+        .match_header("authorization", "Bearer codex-token")
+        .match_header("ChatGPT-Account-Id", "acct-1")
+        .with_body(
+            r#"{"plan_type": "plus", "rate_limit": {"primary_window": {"used_percent": 10, "reset_at": 1788736280}}}"#,
+        )
+        .create();
+
+    let codex_home = TempDir::new().unwrap();
+    fs::write(
+        codex_home.path().join("auth.json"),
+        r#"{"auth_mode": "chatgpt",
+            "tokens": {"access_token": "codex-token", "account_id": "acct-1"}}"#,
+    )
+    .unwrap();
+
+    alpaca()
+        .args(["quota", "-p", "codex", "--base-url", &server.url()])
+        .env("CODEX_HOME", codex_home.path())
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("codex usage").and(predicate::str::contains("5 hours")));
+
+    mock.assert();
+}
+
+#[test]
+fn codex_quota_without_credentials_fails() {
+    // `alpaca()` points CODEX_HOME at an empty directory, so the CLI's auth
+    // file is missing.
+    offline(alpaca().args(["quota", "-p", "codex"]))
+        .assert()
+        .failure()
+        .stderr(predicate::str::contains("no credentials for codex"));
+}
+
+#[test]
 fn openai_quota_reports_the_months_spend_and_model_breakdown() {
     let mut server = mockito::Server::new();
     let costs = server
@@ -495,6 +541,36 @@ fn openai_quota_reports_the_months_spend_and_model_bre
 }
 
 #[test]
+fn an_openai_403_explains_the_admin_key_requirement() {
+    let mut server = mockito::Server::new();
+    server
+        .mock("GET", "/v1/organization/costs")
+        .match_query(mockito::Matcher::Any)
+        .with_status(403)
+        .with_body(r#"{"error":{"message":"Missing scopes: api.usage.read"}}"#)
+        .create();
+
+    offline(alpaca().args([
+        "quota",
+        "-p",
+        "openai",
+        "--apikey",
+        "sk-proj-KEY",
+        "--base-url",
+        &server.url(),
+    ]))
+    .assert()
+    .failure()
+    .stderr(
+        predicate::str::contains("failed to fetch openai quota").and(
+            predicate::str::contains("Missing scopes: api.usage.read").and(
+                predicate::str::contains("platform.openai.com/settings/organization/admin-keys"),
+            ),
+        ),
+    );
+}
+
+#[test]
 fn openai_quota_survives_a_missing_completions_endpoint() {
     let mut server = mockito::Server::new();
     let costs = server
@@ -521,36 +597,3 @@ fn openai_quota_survives_a_missing_completions_endpoin
 
     costs.assert();
 }
-
-#[test]
-fn deepinfra_quota_survives_a_missing_checklist_endpoint() {
-    let mut server = mockito::Server::new();
-    let mock = server
-        .mock("GET", "/payment/usage?from=current")
-        .with_body(
-            r#"{"months": [{"period": "2026.09", "total_cost": 1234, "items": []}],
-                "initial_month": "2024.01"}"#,
-        )
-        .create();
-    // /payment/checklist is left unmocked: the usage report still succeeds
-    // without a credit note.
-
-    alpaca()
-        .args([
-            "quota",
-            "-p",
-            "deepinfra",
-            "--apikey",
-            "KEY",
-            "--base-url",
-            &server.url(),
-        ])
-        .assert()
-        .success()
-        .stdout(
-            predicate::str::contains("total 12.34 USD")
-                .and(predicate::str::contains("credit left").not()),
-        );
-
-    mock.assert();
-}