commit 263a717a36dbe0de46c56e14ce13b4360c82ed16 from: mtmn date: Sun Sep 6 18:33:50 2026 UTC codex 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 `: 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 `: request timeout in seconds - `--apikey `: API key or OAuth token, replacing the provider default - `--base-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, -} - -#[derive(Debug, Deserialize)] -struct Usage { - #[serde(default)] - months: Vec, -} - /// 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 { - 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 { + 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 { - 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 { - let mut totals: Vec = 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, +} + +#[derive(Debug, Default, Deserialize)] +struct RateLimit { + #[serde(default)] + primary_window: Option, + #[serde(default)] + secondary_window: Option, +} + +/// 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, +} + +#[derive(Debug, Default, Deserialize)] +struct Usage { + #[serde(default)] + plan_type: Option, + #[serde(default)] + rate_limit: Option, + #[serde(default)] + credits: Option, +} + +/// 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 { + 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) -> Option> { + 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 { + 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::() + .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, } /// 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, now: DateTime 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 { + 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, timeout: Duration, } @@ -302,7 +361,10 @@ impl Client { timeout: Duration, ) -> Result { 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(); -}