use serde_json::Value;
pub const USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Account {
pub email: Option<String>,
pub plan: Option<String>,
pub limits: crate::codex_limits::Limits,
pub scoped: Vec<(String, crate::codex_limits::Window)>,
pub credits: Option<Credits>,
pub refused: Option<String>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Credits {
pub has_credits: bool,
pub unlimited: bool,
pub overage_limit_reached: bool,
pub balance: Option<String>,
}
fn window_from(v: &Value) -> Option<crate::codex_limits::Window> {
let used_pct = v.get("used_percent")?.as_f64()?;
let window_minutes = v
.get("limit_window_seconds")
.and_then(Value::as_i64)
.map(|s| s / 60)
.filter(|m| *m > 0)?;
Some(crate::codex_limits::Window {
used_pct,
window_minutes,
resets_at: v.get("reset_at").and_then(Value::as_i64).filter(|t| *t > 0),
})
}
fn windows_of(
v: &Value,
) -> (
Option<crate::codex_limits::Window>,
Option<crate::codex_limits::Window>,
) {
let a = v.get("primary_window").and_then(window_from);
let b = v.get("secondary_window").and_then(window_from);
match (a, b) {
(Some(x), Some(y)) if y.window_minutes < x.window_minutes => (Some(y), Some(x)),
pair => pair,
}
}
pub fn parse(body: &str) -> Option<Account> {
let v: Value = serde_json::from_str(body).ok()?;
let rate_limit = v.get("rate_limit")?;
let (short, long) = windows_of(rate_limit);
let scoped = v
.get("additional_rate_limits")
.and_then(Value::as_array)
.map(|xs| {
xs.iter()
.filter_map(|x| {
let name = x.get("limit_name")?.as_str()?.to_string();
let (w, _) = windows_of(x.get("rate_limit")?);
Some((name, w?))
})
.collect()
})
.unwrap_or_default();
let credits = v.get("credits").filter(|c| c.is_object()).map(|c| {
let flag = |k: &str| c.get(k).and_then(Value::as_bool).unwrap_or(false);
Credits {
has_credits: flag("has_credits"),
unlimited: flag("unlimited"),
overage_limit_reached: flag("overage_limit_reached"),
balance: c.get("balance").and_then(Value::as_str).map(str::to_string),
}
});
Some(Account {
email: v.get("email").and_then(Value::as_str).map(str::to_string),
plan: v
.get("plan_type")
.and_then(Value::as_str)
.map(str::to_string),
limits: crate::codex_limits::Limits {
short,
long,
observed_at: None,
},
scoped,
credits,
refused: v
.get("rate_limit_reached_type")
.and_then(Value::as_str)
.map(str::to_string),
})
}
pub fn from_headers(headers: &[(String, String)]) -> Option<Account> {
let get = |want: &str| {
headers
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(want))
.map(|(_, v)| v.trim())
.filter(|v| !v.is_empty())
};
let window = |kind: &str| {
let used_pct: f64 = get(&format!("x-codex-{kind}-used-percent"))?.parse().ok()?;
let window_minutes: i64 = get(&format!("x-codex-{kind}-window-minutes"))?
.parse()
.ok()
.filter(|m| *m > 0)?;
Some(crate::codex_limits::Window {
used_pct,
window_minutes,
resets_at: get(&format!("x-codex-{kind}-reset-at")).and_then(reset_at),
})
};
let (short, long) = match (window("primary"), window("secondary")) {
(Some(x), Some(y)) if y.window_minutes < x.window_minutes => (Some(y), Some(x)),
pair => pair,
};
let refused = get("x-codex-rate-limit-reached-type").map(str::to_string);
if short.is_none() && long.is_none() {
return None;
}
let mut scoped: Vec<(String, crate::codex_limits::Window)> = Vec::new();
let mut ids: Vec<String> = Vec::new();
for (name, _) in headers {
let lower = name.to_ascii_lowercase();
let Some(rest) = lower.strip_prefix("x-codex-") else {
continue;
};
let Some(id) = rest.strip_suffix("-primary-used-percent") else {
continue;
};
if !id.is_empty() && !ids.iter().any(|k| k == id) {
ids.push(id.to_string());
}
}
for id in ids {
let used_pct: Option<f64> =
get(&format!("x-codex-{id}-primary-used-percent")).and_then(|v| v.parse().ok());
let minutes: Option<i64> = get(&format!("x-codex-{id}-primary-window-minutes"))
.and_then(|v| v.parse().ok())
.filter(|m| *m > 0);
if let (Some(used_pct), Some(window_minutes)) = (used_pct, minutes) {
scoped.push((
id.clone(),
crate::codex_limits::Window {
used_pct,
window_minutes,
resets_at: get(&format!("x-codex-{id}-primary-reset-at")).and_then(reset_at),
},
));
}
}
let yes = |k: &str| get(k).map(|v| v.eq_ignore_ascii_case("true"));
let credits = match (
yes("x-codex-credits-has-credits"),
yes("x-codex-credits-unlimited"),
) {
(None, None) => None,
(has, unlimited) => Some(Credits {
has_credits: has.unwrap_or(false),
unlimited: unlimited.unwrap_or(false),
overage_limit_reached: false,
balance: get("x-codex-credits-balance").map(str::to_string),
}),
};
Some(Account {
plan: get("x-codex-plan-type").map(str::to_string),
limits: crate::codex_limits::Limits {
short,
long,
observed_at: None,
},
scoped,
credits,
refused,
..Default::default()
})
}
pub fn refusal_words(kind: &str) -> String {
match kind.trim().to_ascii_lowercase().as_str() {
"rate_limit_reached" => "out of quota".into(),
"workspace_owner_credits_depleted" => "credits spent - top them up in the workspace".into(),
"workspace_member_credits_depleted" => {
"workspace credits spent - its owner has to top them up".into()
}
"workspace_owner_usage_limit_reached" => {
"spend limit reached - raise it in the workspace".into()
}
"workspace_member_usage_limit_reached" => {
"workspace spend limit reached - its owner has to raise it".into()
}
_ => kind.trim().to_string(),
}
}
pub fn remember(
paths: &crate::paths::Paths,
serving: &str,
headers: &[(String, String)],
at: i64,
) -> bool {
let Some(a) = from_headers(headers) else {
return false;
};
let p = crate::codex_limits::place(&a.limits);
let entry = crate::quota_cache::Entry {
five_h: p.five_h.map(|w| w.used_pct),
five_h_reset: p.five_h.and_then(|w| w.resets_at),
seven_d: p.seven_d.map(|w| w.used_pct),
seven_d_reset: p.seven_d.and_then(|w| w.resets_at),
at,
on_credits: a
.credits
.as_ref()
.is_some_and(|c| (c.has_credits || c.unlimited) && !c.overage_limit_reached),
refused: a.refused.as_deref().map(refusal_words),
token_rejected_at: None,
};
crate::quota_cache::update_for(paths, "codex", &[(serving.to_string(), entry)]);
true
}
fn reset_at(v: &str) -> Option<i64> {
match v.parse::<i64>() {
Ok(n) if n > 0 => Some(n),
_ => crate::session_link::rfc3339_to_secs(v),
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Fetch {
Ok(Box<Account>),
Unauthorized,
Throttled,
Unexpected(u32, String),
Offline(String),
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum LiveOutcome {
Reading,
KeepRemembered,
Refused,
}
pub fn outcome_of(f: &Fetch) -> LiveOutcome {
match f {
Fetch::Ok(_) => LiveOutcome::Reading,
Fetch::Unauthorized => LiveOutcome::Refused,
Fetch::Throttled | Fetch::Unexpected(..) | Fetch::Offline(_) => LiveOutcome::KeepRemembered,
}
}
impl Fetch {
pub fn why_no_number(&self) -> Option<&'static str> {
match self {
Self::Ok(_) => None,
Self::Throttled => Some("usage endpoint throttled"),
Self::Unauthorized => Some("token rejected"),
Self::Offline(_) => Some("could not reach the endpoint"),
Self::Unexpected(_, _) => Some("unexpected reply"),
}
}
}
pub fn classify(code: u32, body: String) -> Fetch {
match code {
401 | 403 => Fetch::Unauthorized,
429 => Fetch::Throttled,
200..=299 => match parse(&body) {
Some(a) => Fetch::Ok(Box::new(a)),
None => Fetch::Unexpected(code, body),
},
0 => Fetch::Offline("no response from chatgpt.com".into()),
c => Fetch::Unexpected(c, body),
}
}
pub fn note_token_outcome(paths: &crate::paths::Paths, name: &str, f: &Fetch, at: i64) {
if outcome_of(f) == LiveOutcome::Refused {
crate::quota_cache::note_token_rejected(paths, "codex", name, at);
}
}
pub fn fetch(auth: &crate::proxy::codex::Auth) -> Fetch {
let Ok(token) = std::str::from_utf8(auth.token.expose()) else {
return Fetch::Unauthorized;
};
if !crate::quota::token_usable(token) {
return Fetch::Unauthorized;
}
let mut cfg = format!(
"url = \"{USAGE_URL}\"\n\
header = \"Authorization: Bearer {token}\"\n\
header = \"Accept: application/json\"\n\
header = \"User-Agent: swapdex\"\n"
);
if workspace_id(&auth.account_id) {
cfg.push_str(&format!(
"header = \"chatgpt-account-id: {}\"\n",
auth.account_id
));
}
cfg.push_str(
"silent\n\
show-error\n\
connect-timeout = 6\n\
max-time = 15\n\
write-out = \"\\n%{http_code}\"\n",
);
match crate::quota::run_curl_cfg(&cfg) {
Ok((body, code)) => classify(code, body),
Err(e) => Fetch::Offline(e),
}
}
fn workspace_id(id: &str) -> bool {
let id = id.trim();
!id.is_empty()
&& !id.starts_with("email_")
&& !id.starts_with("local_")
&& crate::quota::token_usable(id)
}
#[cfg(test)]
mod tests {
use super::*;
const BODY: &str = r#"{
"user_id": "user-EXAMPLE",
"account_id": "00000000-0000-0000-0000-000000000000",
"email": "someone@example.com",
"plan_type": "pro",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 84,
"limit_window_seconds": 604800,
"reset_after_seconds": 501720,
"reset_at": 1787196620
},
"secondary_window": null
},
"code_review_rate_limit": null,
"additional_rate_limits": [
{
"limit_name": "GPT-5.3-Codex-Spark",
"metered_feature": "codex_bengalfox",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 0,
"limit_window_seconds": 604800,
"reset_after_seconds": 604800,
"reset_at": 1787299682
},
"secondary_window": null
}
}
],
"credits": {
"has_credits": false,
"unlimited": false,
"overage_limit_reached": false,
"balance": "0"
},
"spend_control": { "reached": false, "individual_limit": null },
"rate_limit_reached_type": null
}"#;
#[test]
fn a_reading_names_the_account_it_came_from() {
let a = parse(BODY).expect("a recorded response parses");
assert_eq!(a.email.as_deref(), Some("someone@example.com"));
assert_eq!(a.plan.as_deref(), Some("pro"));
}
#[test]
fn windows_carry_their_length_in_minutes() {
let a = parse(BODY).expect("parses");
let w = a.limits.short.expect("the plan window");
assert_eq!(w.used_pct, 84.0);
assert_eq!(w.window_minutes, 10_080);
assert_eq!(w.resets_at, Some(1_787_196_620));
assert_eq!(a.limits.long, None);
}
#[test]
fn per_model_limits_are_kept_under_their_own_names() {
let a = parse(BODY).expect("parses");
assert_eq!(a.scoped.len(), 1);
assert_eq!(a.scoped[0].0, "GPT-5.3-Codex-Spark");
assert_eq!(a.scoped[0].1.used_pct, 0.0);
}
#[test]
fn credits_are_read_whole() {
let a = parse(BODY).expect("parses");
let c = a.credits.expect("the response describes credits");
assert!(!c.has_credits);
assert!(!c.overage_limit_reached);
assert_eq!(c.balance.as_deref(), Some("0"));
assert_eq!(a.refused, None);
}
#[test]
fn a_refusal_is_carried_verbatim() {
let body = BODY.replace(
"\"rate_limit_reached_type\": null",
"\"rate_limit_reached_type\": \"usage_limit\"",
);
assert_eq!(
parse(&body).unwrap().refused.as_deref(),
Some("usage_limit")
);
}
#[test]
fn a_body_that_is_not_this_endpoint_is_not_guessed_at() {
assert_eq!(parse("not json"), None);
assert_eq!(parse(r#"{"error":"unauthorized"}"#), None);
}
#[test]
fn each_failure_keeps_its_own_name() {
assert_eq!(classify(429, String::new()), Fetch::Throttled);
assert_eq!(classify(401, String::new()), Fetch::Unauthorized);
assert_eq!(classify(403, String::new()), Fetch::Unauthorized);
assert!(matches!(
classify(200, "<html>login</html>".into()),
Fetch::Unexpected(200, _)
));
assert!(classify(200, BODY.into()).why_no_number().is_none());
assert_eq!(
classify(429, String::new()).why_no_number(),
Some("usage endpoint throttled")
);
}
fn h(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect()
}
#[test]
fn windows_are_read_off_a_response_the_proxy_already_has() {
let got = from_headers(&h(&[
("content-type", "text/event-stream"),
("X-Codex-Primary-Used-Percent", "42.5"),
("x-codex-primary-window-minutes", "10080"),
("x-codex-primary-reset-at", "1787196620"),
("x-codex-secondary-used-percent", "7"),
("x-codex-secondary-window-minutes", "300"),
("x-codex-secondary-reset-at", "1786600000"),
]))
.expect("these headers are a reading");
let short = got.limits.short.expect("the session window");
assert_eq!(short.window_minutes, 300);
assert_eq!(short.used_pct, 7.0);
assert_eq!(short.resets_at, Some(1_786_600_000));
let long = got.limits.long.expect("the weekly window");
assert_eq!(long.window_minutes, 10080);
assert_eq!(long.used_pct, 42.5);
}
#[test]
fn a_zero_length_window_is_a_placeholder_not_an_empty_one() {
let got = from_headers(&h(&[
("x-codex-active-limit", "premium"),
("x-codex-plan-type", "pro"),
("x-codex-primary-used-percent", "40"),
("x-codex-secondary-used-percent", "0"),
("x-codex-primary-window-minutes", "10080"),
("x-codex-secondary-window-minutes", "0"),
("x-codex-primary-reset-after-seconds", "178592"),
("x-codex-secondary-reset-after-seconds", "0"),
("x-codex-primary-reset-at", "1787196937"),
("x-codex-secondary-reset-at", ""),
]))
.expect("the primary window is real");
let w = got.limits.short.expect("the one real window");
assert_eq!(w.window_minutes, 10080);
assert_eq!(w.used_pct, 40.0);
assert_eq!(w.resets_at, Some(1_787_196_937));
assert_eq!(got.limits.long, None);
}
#[test]
fn a_response_of_nothing_but_placeholders_is_not_a_reading() {
assert!(from_headers(&h(&[
("x-codex-primary-used-percent", "0"),
("x-codex-primary-window-minutes", "0"),
("x-codex-secondary-used-percent", "0"),
("x-codex-secondary-window-minutes", "0"),
]))
.is_none());
}
#[test]
fn the_endpoint_rejects_a_zero_length_window_too() {
let body = r#"{"rate_limit":{"primary_window":{"used_percent":40,"limit_window_seconds":604800,"reset_at":1787196937},"secondary_window":{"used_percent":0,"limit_window_seconds":0,"reset_at":0}}}"#;
let a = parse(body).expect("the primary window is real");
assert_eq!(a.limits.short.expect("one window").window_minutes, 10080);
assert_eq!(a.limits.long, None);
}
#[test]
fn a_response_carries_the_plan_the_credits_and_the_per_model_limits() {
let got = from_headers(&h(&[
("x-codex-plan-type", "pro"),
("x-codex-primary-used-percent", "40"),
("x-codex-primary-window-minutes", "10080"),
("x-codex-primary-reset-at", "1787196937"),
("x-codex-primary-over-secondary-limit-percent", "0"),
("x-codex-credits-has-credits", "False"),
("x-codex-credits-balance", "0"),
("x-codex-credits-unlimited", "False"),
("x-codex-bengalfox-primary-used-percent", "12"),
("x-codex-bengalfox-primary-window-minutes", "10080"),
("x-codex-bengalfox-primary-reset-after-seconds", "604800"),
("x-codex-bengalfox-secondary-used-percent", "0"),
("x-codex-bengalfox-secondary-window-minutes", "0"),
]))
.expect("a reading");
assert_eq!(got.plan.as_deref(), Some("pro"));
let c = got.credits.expect("the response describes credits");
assert!(!c.has_credits);
assert!(!c.unlimited);
assert_eq!(c.balance.as_deref(), Some("0"));
assert_eq!(got.scoped.len(), 1, "{:?}", got.scoped);
assert_eq!(got.scoped[0].0, "bengalfox");
assert_eq!(got.scoped[0].1.used_pct, 12.0);
assert_eq!(got.scoped[0].1.window_minutes, 10080);
assert_eq!(got.limits.long, None);
}
#[test]
fn a_neighbouring_percent_header_is_not_mistaken_for_a_limit() {
let got = from_headers(&h(&[
("x-codex-primary-used-percent", "40"),
("x-codex-primary-window-minutes", "10080"),
("x-codex-primary-over-secondary-limit-percent", "0"),
]))
.expect("a reading");
assert!(got.scoped.is_empty(), "{:?}", got.scoped);
}
#[test]
fn credits_on_the_response_are_remembered_with_the_numbers() {
let root = tempfile::tempdir().unwrap();
let paths = crate::paths::Paths::rooted(root.path());
let base = [
("x-codex-primary-used-percent", "100"),
("x-codex-primary-window-minutes", "10080"),
];
let with = |extra: &[(&str, &str)]| {
let mut v: Vec<(&str, &str)> = base.to_vec();
v.extend_from_slice(extra);
h(&v)
};
assert!(remember(
&paths,
"flush",
&with(&[("x-codex-credits-has-credits", "True")]),
1_786_600_000
));
assert!(crate::quota_cache::load_for(&paths, "codex")["flush"].on_credits);
assert!(remember(
&paths,
"dry",
&with(&[("x-codex-credits-has-credits", "False")]),
1_786_600_000
));
assert!(!crate::quota_cache::load_for(&paths, "codex")["dry"].on_credits);
assert!(remember(&paths, "quiet", &with(&[]), 1_786_600_000));
assert!(!crate::quota_cache::load_for(&paths, "codex")["quiet"].on_credits);
}
#[test]
fn a_refusal_reason_survives_to_the_remembered_reading() {
let root = tempfile::tempdir().unwrap();
let paths = crate::paths::Paths::rooted(root.path());
assert!(remember(
&paths,
"work",
&h(&[
("x-codex-primary-used-percent", "100"),
("x-codex-primary-window-minutes", "10080"),
(
"x-codex-rate-limit-reached-type",
"workspace_member_credits_depleted"
),
]),
1_786_600_000
));
let c = crate::quota_cache::load_for(&paths, "codex");
assert_eq!(
c["work"].refused.as_deref(),
Some("workspace credits spent - its owner has to top them up")
);
assert!(remember(
&paths,
"quiet",
&h(&[
("x-codex-primary-used-percent", "10"),
("x-codex-primary-window-minutes", "10080"),
]),
1_786_600_000
));
assert_eq!(
crate::quota_cache::load_for(&paths, "codex")["quiet"].refused,
None
);
}
#[test]
fn a_reset_time_is_read_in_either_form_it_arrives_in() {
let iso = from_headers(&h(&[
("x-codex-primary-used-percent", "10"),
("x-codex-primary-window-minutes", "10080"),
("x-codex-primary-reset-at", "2026-08-18T04:30:20Z"),
]))
.expect("a reading");
assert_eq!(iso.limits.short.unwrap().resets_at, Some(1_787_027_420));
}
#[test]
fn a_response_without_them_is_not_a_reading() {
assert!(from_headers(&h(&[("content-type", "application/json")])).is_none());
assert!(from_headers(&h(&[("x-codex-primary-used-percent", "42")])).is_none());
}
#[test]
fn a_refusal_reason_on_the_response_is_kept() {
let got = from_headers(&h(&[
("x-codex-primary-used-percent", "100"),
("x-codex-primary-window-minutes", "10080"),
("x-codex-rate-limit-reached-type", "usage_limit"),
]))
.expect("a reading");
assert_eq!(got.refused.as_deref(), Some("usage_limit"));
}
#[test]
fn a_response_reading_is_remembered_under_the_serving_account() {
let root = tempfile::tempdir().unwrap();
let paths = crate::paths::Paths::rooted(root.path());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let resets = now + 3 * 86_400;
let headers = h(&[
("x-codex-primary-used-percent", "42.5"),
("x-codex-primary-window-minutes", "10080"),
("x-codex-primary-reset-at", &resets.to_string()),
]);
assert!(remember(&paths, "work", &headers, now));
let c = crate::quota_cache::load_for(&paths, "codex");
let e = c.get("work").expect("the serving account was recorded");
assert_eq!(e.seven_d, Some(42.5));
assert_eq!(e.seven_d_reset, Some(resets));
assert_eq!(e.five_h, None);
assert_eq!(e.at, now);
assert!(!remember(
&paths,
"work",
&h(&[("content-type", "text/plain")]),
now + 900
));
let c = crate::quota_cache::load_for(&paths, "codex");
assert_eq!(c.get("work").map(|e| e.at), Some(now));
}
#[test]
fn a_refusal_says_what_happened_and_who_can_clear_it() {
assert_eq!(refusal_words("rate_limit_reached"), "out of quota");
assert_eq!(
refusal_words("workspace_owner_credits_depleted"),
"credits spent - top them up in the workspace"
);
assert_eq!(
refusal_words("workspace_member_credits_depleted"),
"workspace credits spent - its owner has to top them up"
);
assert_eq!(
refusal_words("workspace_owner_usage_limit_reached"),
"spend limit reached - raise it in the workspace"
);
assert_eq!(
refusal_words("workspace_member_usage_limit_reached"),
"workspace spend limit reached - its owner has to raise it"
);
assert_eq!(refusal_words(" RATE_LIMIT_REACHED "), "out of quota");
}
#[test]
fn an_unknown_refusal_is_shown_rather_than_swallowed() {
assert_eq!(refusal_words("some_new_thing"), "some_new_thing");
}
#[test]
fn placeholder_account_ids_are_not_sent_as_a_workspace() {
assert!(workspace_id("0ed4911f-efae-43fd-a2a7-b5fcbec47e10"));
assert!(!workspace_id("email_someone@example.com"));
assert!(!workspace_id("local_abc"));
assert!(!workspace_id(" "));
assert!(!workspace_id("id\"\nheader = \"X: y"));
}
}
#[cfg(test)]
mod live_outcome_tests {
use super::*;
#[test]
fn a_rejected_token_is_not_the_same_as_a_busy_endpoint() {
assert_eq!(outcome_of(&Fetch::Unauthorized), LiveOutcome::Refused);
assert_eq!(outcome_of(&Fetch::Throttled), LiveOutcome::KeepRemembered);
assert_eq!(
outcome_of(&Fetch::Unexpected(500, "boom".into())),
LiveOutcome::KeepRemembered,
"a reply we could not read says nothing about the account either"
);
}
}