use super::model::DEFAULT_CODEX_HOME_ENV;
use reqwest::blocking::Client;
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::time::Duration;
const CODEX_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
const LIMIT_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
const LIMIT_REQUEST_TIMEOUT: Duration = Duration::from_secs(4);
const CODEX_USER_AGENT: &str = "codex-cli";
const CODEX_PRODUCT_SKU: &str = "codex";
const CODEX_FIVE_HOUR_WINDOW_MINUTES: i64 = 5 * 60;
const CODEX_WEEKLY_WINDOW_MINUTES: i64 = 7 * 24 * 60;
#[derive(Clone, Debug, PartialEq)]
pub(in crate::app) enum CodexLimitStatus {
Available(CodexLimits),
Unavailable(CodexLimitUnavailableReason),
}
impl CodexLimitStatus {
pub(in crate::app) const fn unavailable(reason: CodexLimitUnavailableReason) -> Self {
Self::Unavailable(reason)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(in crate::app) enum CodexLimitUnavailableReason {
Offline,
AuthMissing,
InvalidAuth,
UnsupportedAuth,
RequestFailed,
Unauthorized,
InvalidResponse,
NoLimitData,
}
impl CodexLimitUnavailableReason {
pub(in crate::app) const fn as_str(self) -> &'static str {
match self {
Self::Offline => "offline",
Self::AuthMissing => "auth.json missing",
Self::InvalidAuth => "invalid auth.json",
Self::UnsupportedAuth => "unsupported auth",
Self::RequestFailed => "request failed",
Self::Unauthorized => "unauthorized",
Self::InvalidResponse => "invalid response",
Self::NoLimitData => "no limit data",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub(in crate::app) struct CodexLimits {
pub(in crate::app) five_hour: Option<CodexLimitWindow>,
pub(in crate::app) weekly: Option<CodexLimitWindow>,
}
impl CodexLimits {
const fn has_window(&self) -> bool {
self.five_hour.is_some() || self.weekly.is_some()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(in crate::app) struct CodexLimitWindow {
pub(in crate::app) used_percent: f64,
pub(in crate::app) window_minutes: Option<i64>,
pub(in crate::app) resets_at_epoch_seconds: Option<i64>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct CodexAuthCredentials {
access_token: String,
account_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AuthFile {
#[serde(default, rename = "OPENAI_API_KEY")]
openai_api_key: Option<String>,
#[serde(default)]
tokens: Option<AuthTokens>,
#[serde(default)]
agent_identity: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AuthTokens {
access_token: String,
#[serde(default)]
account_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct UsagePayload {
#[serde(default, alias = "rateLimits")]
rate_limits: Option<ProtocolRateLimitSnapshot>,
#[serde(default, alias = "rateLimitsByLimitId")]
rate_limits_by_limit_id: Option<BTreeMap<String, ProtocolRateLimitSnapshot>>,
#[serde(default, alias = "rateLimit")]
rate_limit: Option<RateLimitDetails>,
#[serde(default, alias = "additionalRateLimits")]
additional_rate_limits: Option<Vec<AdditionalRateLimit>>,
}
#[derive(Clone, Debug, Deserialize)]
struct ProtocolRateLimitSnapshot {
#[serde(default, alias = "limitId")]
limit_id: Option<String>,
#[serde(default)]
primary: Option<ProtocolRateLimitWindow>,
#[serde(default)]
secondary: Option<ProtocolRateLimitWindow>,
}
#[derive(Clone, Copy, Debug, Deserialize)]
struct ProtocolRateLimitWindow {
#[serde(default, alias = "usedPercent")]
used_percent: Option<f64>,
#[serde(default, alias = "windowDurationMins")]
window_duration_mins: Option<i64>,
#[serde(default, alias = "resetsAt")]
resets_at: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct AdditionalRateLimit {
#[serde(default, alias = "meteredFeature")]
metered_feature: Option<String>,
#[serde(default, alias = "limitName")]
limit_name: Option<String>,
#[serde(default, alias = "rateLimit")]
rate_limit: Option<RateLimitDetails>,
}
#[derive(Clone, Copy, Debug, Deserialize)]
struct RateLimitDetails {
#[serde(default, alias = "primaryWindow", alias = "primary")]
primary_window: Option<RateLimitWindowPayload>,
#[serde(default, alias = "secondaryWindow", alias = "secondary")]
secondary_window: Option<RateLimitWindowPayload>,
}
#[derive(Clone, Copy, Debug, Deserialize)]
struct RateLimitWindowPayload {
#[serde(default, alias = "usedPercent")]
used_percent: Option<f64>,
#[serde(default, alias = "limitWindowSeconds")]
limit_window_seconds: Option<i64>,
#[serde(default, alias = "resetAt", alias = "resetsAt")]
reset_at: Option<i64>,
}
pub(in crate::app) fn fetch_codex_limits() -> CodexLimitStatus {
let credentials = match load_codex_auth_credentials(&default_codex_auth_path()) {
Ok(credentials) => credentials,
Err(reason) => return CodexLimitStatus::unavailable(reason),
};
fetch_codex_limits_with_credentials(&credentials)
}
fn default_codex_auth_path() -> PathBuf {
codex_home_from_env(
std::env::var_os(DEFAULT_CODEX_HOME_ENV),
std::env::var_os("HOME"),
)
.join("auth.json")
}
fn codex_home_from_env(codex_home: Option<OsString>, home: Option<OsString>) -> PathBuf {
codex_home
.map(PathBuf::from)
.or_else(|| home.map(|home| PathBuf::from(home).join(".codex")))
.unwrap_or_else(|| PathBuf::from(".codex"))
}
fn load_codex_auth_credentials(
auth_path: &Path,
) -> Result<CodexAuthCredentials, CodexLimitUnavailableReason> {
let raw = std::fs::read_to_string(auth_path).map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
CodexLimitUnavailableReason::AuthMissing
} else {
CodexLimitUnavailableReason::InvalidAuth
}
})?;
codex_auth_credentials_from_json(&raw)
}
fn codex_auth_credentials_from_json(
raw: &str,
) -> Result<CodexAuthCredentials, CodexLimitUnavailableReason> {
let auth: AuthFile =
serde_json::from_str(raw).map_err(|_| CodexLimitUnavailableReason::InvalidAuth)?;
let Some(tokens) = auth.tokens else {
let has_other_auth = auth.openai_api_key.is_some() || auth.agent_identity.is_some();
return Err(if has_other_auth {
CodexLimitUnavailableReason::UnsupportedAuth
} else {
CodexLimitUnavailableReason::InvalidAuth
});
};
let access_token = tokens.access_token.trim();
if access_token.is_empty() {
return Err(CodexLimitUnavailableReason::InvalidAuth);
}
let account_id = tokens
.account_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
Ok(CodexAuthCredentials {
access_token: access_token.to_string(),
account_id,
})
}
fn fetch_codex_limits_with_credentials(credentials: &CodexAuthCredentials) -> CodexLimitStatus {
let Ok(client) = Client::builder()
.connect_timeout(LIMIT_CONNECT_TIMEOUT)
.timeout(LIMIT_REQUEST_TIMEOUT)
.build()
else {
return CodexLimitStatus::unavailable(CodexLimitUnavailableReason::RequestFailed);
};
let Some(headers) = usage_request_headers(credentials) else {
return CodexLimitStatus::unavailable(CodexLimitUnavailableReason::InvalidAuth);
};
let Ok(response) = client.get(CODEX_USAGE_URL).headers(headers).send() else {
return CodexLimitStatus::unavailable(CodexLimitUnavailableReason::RequestFailed);
};
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
return CodexLimitStatus::unavailable(CodexLimitUnavailableReason::Unauthorized);
}
if !response.status().is_success() {
return CodexLimitStatus::unavailable(CodexLimitUnavailableReason::RequestFailed);
}
let Ok(body) = response.text() else {
return CodexLimitStatus::unavailable(CodexLimitUnavailableReason::RequestFailed);
};
codex_limits_from_usage_payload(&body)
}
fn usage_request_headers(credentials: &CodexAuthCredentials) -> Option<HeaderMap> {
let mut headers = HeaderMap::new();
let auth = HeaderValue::from_str(&format!("Bearer {}", credentials.access_token)).ok()?;
headers.insert(AUTHORIZATION, auth);
headers.insert(USER_AGENT, HeaderValue::from_static(CODEX_USER_AGENT));
headers.insert(
"OAI-Product-Sku",
HeaderValue::from_static(CODEX_PRODUCT_SKU),
);
if let Some(account_id) = credentials.account_id.as_deref() {
let account = HeaderValue::from_str(account_id).ok()?;
headers.insert("ChatGPT-Account-ID", account);
}
Some(headers)
}
fn codex_limits_from_usage_payload(raw: &str) -> CodexLimitStatus {
let Ok(payload) = serde_json::from_str::<UsagePayload>(raw) else {
return CodexLimitStatus::unavailable(CodexLimitUnavailableReason::InvalidResponse);
};
if let Some(limits) = codex_protocol_limits(&payload) {
return codex_limit_status_from_limits(limits);
}
let Some(limits) = codex_backend_limits(payload) else {
return CodexLimitStatus::unavailable(CodexLimitUnavailableReason::NoLimitData);
};
codex_limit_status_from_limits(limits)
}
fn codex_limit_status_from_limits(limits: CodexLimits) -> CodexLimitStatus {
if limits.has_window() {
CodexLimitStatus::Available(limits)
} else {
CodexLimitStatus::unavailable(CodexLimitUnavailableReason::NoLimitData)
}
}
fn codex_protocol_limits(payload: &UsagePayload) -> Option<CodexLimits> {
let snapshot = payload
.rate_limits_by_limit_id
.as_ref()
.and_then(|by_id| by_id.get("codex"))
.or_else(|| {
payload
.rate_limits_by_limit_id
.as_ref()?
.values()
.find(|snapshot| snapshot.limit_id.as_deref() == Some("codex"))
})
.or_else(|| {
payload
.rate_limits
.as_ref()
.filter(|snapshot| snapshot.limit_id.as_deref().is_none_or(|id| id == "codex"))
})?;
codex_limits_from_windows([
protocol_rate_limit_window(snapshot.primary),
protocol_rate_limit_window(snapshot.secondary),
])
}
fn codex_backend_limits(payload: UsagePayload) -> Option<CodexLimits> {
let mut windows = Vec::new();
if let Some(details) = payload.rate_limit {
append_rate_limit_windows(&mut windows, details);
}
if let Some(additional_limits) = payload.additional_rate_limits {
for limit in additional_limits
.into_iter()
.filter(is_codex_additional_limit)
{
if let Some(details) = limit.rate_limit {
append_rate_limit_windows(&mut windows, details);
}
}
}
codex_limits_from_windows(windows)
}
fn is_codex_additional_limit(limit: &AdditionalRateLimit) -> bool {
limit.metered_feature.as_deref() == Some("codex")
|| limit.limit_name.as_deref() == Some("codex")
}
fn append_rate_limit_windows(
windows: &mut Vec<Option<CodexLimitWindow>>,
details: RateLimitDetails,
) {
windows.extend([
rate_limit_window(details.primary_window),
rate_limit_window(details.secondary_window),
]);
}
fn codex_limits_from_windows(
windows: impl IntoIterator<Item = Option<CodexLimitWindow>>,
) -> Option<CodexLimits> {
let mut five_hour = None;
let mut weekly = None;
let mut unknown_duration = Vec::new();
for window in windows.into_iter().flatten() {
match window.window_minutes {
Some(CODEX_FIVE_HOUR_WINDOW_MINUTES) if five_hour.is_none() => {
five_hour = Some(window);
}
Some(CODEX_WEEKLY_WINDOW_MINUTES) if weekly.is_none() => {
weekly = Some(window);
}
Some(_) => {}
None => unknown_duration.push(window),
}
}
let mut unknown_duration = unknown_duration.into_iter();
if five_hour.is_none() {
five_hour = unknown_duration.next();
}
if weekly.is_none() {
weekly = unknown_duration.next();
}
let limits = CodexLimits { five_hour, weekly };
limits.has_window().then_some(limits)
}
fn protocol_rate_limit_window(window: Option<ProtocolRateLimitWindow>) -> Option<CodexLimitWindow> {
let window = window?;
let used_percent = window.used_percent?;
if !used_percent.is_finite() {
return None;
}
Some(CodexLimitWindow {
used_percent: used_percent.clamp(0.0, 100.0),
window_minutes: window.window_duration_mins.filter(|minutes| *minutes > 0),
resets_at_epoch_seconds: window.resets_at,
})
}
fn rate_limit_window(window: Option<RateLimitWindowPayload>) -> Option<CodexLimitWindow> {
let window = window?;
let used_percent = window.used_percent?;
if !used_percent.is_finite() {
return None;
}
Some(CodexLimitWindow {
used_percent: used_percent.clamp(0.0, 100.0),
window_minutes: window
.limit_window_seconds
.filter(|seconds| *seconds > 0)
.map(|seconds| (seconds + 59) / 60),
resets_at_epoch_seconds: window.reset_at,
})
}
pub(in crate::app) fn codex_limit_status_for_watch_start(
offline: bool,
fetch: impl FnOnce() -> CodexLimitStatus,
) -> CodexLimitStatus {
if offline {
CodexLimitStatus::unavailable(CodexLimitUnavailableReason::Offline)
} else {
fetch()
}
}
#[cfg(test)]
#[path = "codex_limits_tests.rs"]
mod tests;