commit - 1a10940bb412ceb68b371b20795b8b5d24f3cb81
commit + 263a717a36dbe0de46c56e14ce13b4360c82ed16
blob - e2cb50c19595d3cdee57e6e9dff06174eeef89c0
blob + 48fb9572cebdccd79bb533df8901ccc060f920bd
--- Cargo.lock
+++ Cargo.lock
[[package]]
name = "alpaca"
-version = "0.7.1"
+version = "0.7.2"
dependencies = [
"anyhow",
"assert_cmd",
blob - 1fd96a6f875c256600daa97f3f43d7e8414c6d4d
blob + 018a5c92c417e149cb77eb8c435ada23ff43d923
--- Cargo.toml
+++ Cargo.toml
[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
$ 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
| `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
(`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
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
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
# 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
Self::Anthropic,
Self::OpenAI,
Self::DeepInfra,
+ Self::Codex,
]
}
Self::Anthropic => "anthropic",
Self::OpenAI => "openai",
Self::DeepInfra => "deepinfra",
+ Self::Codex => "codex",
}))
}
}
blob - 20b0a13b09b09c7dbc840633541993bfeba410a6
blob + ad3e91cdd1979d4083e6e59b89ec90c7e2e3dcf3
--- src/exec/quota.rs
+++ src/exec/quota.rs
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();
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
-//! `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
+//! `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
//! about quota windows and per-model request counts.
pub mod anthropic;
+pub mod codex;
pub mod deepinfra;
pub mod openai;
pub mod render;
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
Anthropic,
OpenAI,
DeepInfra,
+ Codex,
}
/// Provider usage, normalised for rendering
/// 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)
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
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(),
}
}
// 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,
}
}
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
)
}
/// `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
///
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 })
}
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),
}
}
Self::Anthropic => "anthropic",
Self::OpenAI => "openai",
Self::DeepInfra => "deepinfra",
+ Self::Codex => "codex",
};
f.write_str(name)
}
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,
}
timeout: Duration,
) -> Result<Self, Error> {
let credential = match api_key {
- Some(token) => Credential { token },
+ Some(token) => Credential {
+ token,
+ account_id: None,
+ },
None => provider.credential()?,
};
provider,
base_url: base_url.unwrap_or_else(|| provider.base_url()),
api_key: credential.token,
+ account_id: credential.account_id,
timeout,
})
}
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
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
use assert_cmd::Command;
use predicates::prelude::*;
+use std::fs;
use std::sync::LazyLock;
use tempfile::TempDir;
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
}
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")),
);
}
.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();
}
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")),
);
}
}
#[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",
])
.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
}
#[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
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();
-}