Add Codex JSON sessions and usage limits

This commit is contained in:
iris committed 2026-09-07 23:29:15 -04:00
1 parent 0862b47f76
commit 6a0202b1b5
14 files changed
+1173 -52

No files matched your search

+158 -4
View File
@@ -1,4 +1,4 @@
//! Usage-limit reporting -- the same numbers as Claude Code's `/usage`.
//! Usage-limit reporting -- the same numbers the provider CLIs show.
//!
//! Polls `https://api.anthropic.com/api/oauth/usage` with the OAuth access
//! token from Claude Code's local credential store. The endpoint is
@@ -12,8 +12,8 @@
//! below enforces the latter across any number of phone refreshes; there is no
//! background poll at all.
//!
//! One [`UsageProvider`] per paid service, so a second service later is a new
//! impl behind the same snapshot shape, not a parallel screen.
//! One [`UsageProvider`] per paid service keeps each provider's wire format
//! behind the same snapshot shape.
//!
//! **Asked of the machine that spends the tokens, not of this one.** A session
//! runs wherever its setup says, so the account being billed is that machine's.
@@ -21,7 +21,7 @@
//! no `claude` CLI, and the CLI machine is a remote -- so the one set of numbers
//! the screen could show would be an account with no sessions. Credentials are
//! read through the session `Transport`, one snapshot per setup that offers
//! Claude.
//! that provider.
//!
//! The token is read *to* the backend and the HTTP call is made from here, so
//! the far machine needs nothing beyond a shell and the wire format stays in
@@ -112,6 +112,8 @@ pub struct UsageSnapshot {
/// labels a row with, and [`crate::config::DriverKind::usage_provider`],
/// which is how a session says which of those rows is about it.
pub const CLAUDE: &str = "claude";
/// ChatGPT-backed Codex CLI subscription usage.
pub const CODEX: &str = "codex";
/// The invented one, for testing the screens that draw these -- see
/// [`Fixture`].
pub const ECHO: &str = "echo";
@@ -216,6 +218,129 @@ impl UsageProvider for ClaudeUsage {
}
}
/// Reads the same snapshot as Codex's status display through the CLI's local
/// app-server protocol. The CLI owns authentication and token refresh; this
/// process never opens or copies its credentials.
pub struct CodexUsage {
pub setup: String,
pub setup_name: String,
pub transport: Transport,
pub program: String,
}
impl CodexUsage {
fn snapshot(&self, state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
UsageSnapshot {
provider: CODEX.to_string(),
setup: self.setup.clone(),
setup_name: self.setup_name.clone(),
state,
windows,
fetched_at: crate::session::now(),
}
}
}
impl UsageProvider for CodexUsage {
fn name(&self) -> &'static str {
CODEX
}
fn fetch(&self) -> UsageSnapshot {
let launch = Launch::new(
&self.program,
vec!["app-server".to_string(), "--stdio".to_string()],
None,
);
let initialized = serde_json::json!({
"id": 1,
"method": "initialize",
"params": {"clientInfo": {"name": "ai-app", "title": "AI Sessions", "version": env!("CARGO_PKG_VERSION")}}
});
let requests = [
serde_json::json!({"method": "initialized"}),
serde_json::json!({"id": 2, "method": "account/rateLimits/read"}),
];
let answer = match self
.transport
.request_json_blocking(&launch, &initialized, &requests, 2)
{
Ok(answer) => answer,
Err(err) => {
return self.snapshot(
UsageState::Unreachable {
detail: format!("couldn't ask Codex on {}: {err:#}", self.setup_name),
},
Vec::new(),
);
}
};
if let Some(error) = answer.pointer("/error/message").and_then(Value::as_str) {
let state = if error.to_ascii_lowercase().contains("login")
|| error.to_ascii_lowercase().contains("authentication")
{
UsageState::NotLoggedIn
} else {
UsageState::Failed {
detail: error.to_string(),
}
};
return self.snapshot(state, Vec::new());
}
let Some(limits) = answer.pointer("/result/rateLimits") else {
return self.snapshot(
UsageState::Failed {
detail: "Codex returned no rate-limit snapshot".to_string(),
},
Vec::new(),
);
};
self.snapshot(UsageState::Ok, parse_codex_windows(limits))
}
}
fn parse_codex_windows(limits: &Value) -> Vec<UsageWindow> {
[("primary", true), ("secondary", false)]
.into_iter()
.filter_map(|(kind, primary)| {
let window = limits.get(kind)?;
if window.is_null() {
return None;
}
let minutes = window.get("windowDurationMins").and_then(Value::as_u64);
let label = match minutes {
Some(300) => "5-hour window".to_string(),
Some(10_080) => "Weekly".to_string(),
Some(minutes) if minutes % 1_440 == 0 => {
format!("{}-day window", minutes / 1_440)
}
Some(minutes) if minutes % 60 == 0 => {
format!("{}-hour window", minutes / 60)
}
Some(minutes) => format!("{minutes}-minute window"),
None if primary => "Primary window".to_string(),
None => "Secondary window".to_string(),
};
Some(UsageWindow {
// Common semantic names: the phone's compact bar asks for
// `session`, and auto-resume treats all windows alike.
kind: if primary { "session" } else { "weekly_all" }.to_string(),
label,
percent: window.get("usedPercent")?.as_f64()?,
resets_at: window
.get("resetsAt")
.and_then(Value::as_i64)
.and_then(|seconds| time::OffsetDateTime::from_unix_timestamp(seconds).ok())
.and_then(|at| {
at.format(&time::format_description::well_known::Rfc3339)
.ok()
}),
active: primary,
})
})
.collect()
}
/// Why one call to the usage endpoint did not produce numbers.
///
/// 401 is apart from the rest because it is the only one with a way out: the
@@ -621,6 +746,12 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsagePro
transport: Transport::for_setup(setup),
program: provider.program().to_string(),
})),
CODEX => found.push(Box::new(CodexUsage {
setup: setup.id.clone(),
setup_name: setup.name.clone(),
transport: Transport::for_setup(setup),
program: provider.program().to_string(),
})),
// Nothing at all until a test has asked for something: an
// echo session costs nothing, so the honest answer is no row
// rather than a row saying zero.
@@ -763,6 +894,7 @@ mod tests {
.expect("json");
let windows = parse_windows(&body);
assert_eq!(windows.len(), 4);
assert_eq!(windows[0].kind, "session");
assert_eq!(windows[0].label, "5-hour window");
assert_eq!(windows[0].percent, 70.0);
assert!(windows[0].active);
@@ -873,6 +1005,7 @@ mod tests {
assert_eq!(found[0].name(), ECHO);
assert_eq!(DriverKind::Echo.usage_provider(), Some(ECHO));
assert_eq!(DriverKind::ClaudeCli.usage_provider(), Some(CLAUDE));
assert_eq!(DriverKind::CodexCli.usage_provider(), Some(CODEX));
// A local model costs nothing to run, so it meters nothing.
assert_eq!(DriverKind::LlamaCpp.usage_provider(), None);
}
@@ -920,4 +1053,25 @@ mod tests {
assert!(parse_windows(&serde_json::json!({})).is_empty());
assert!(parse_windows(&serde_json::json!({"limits": "what"})).is_empty());
}
#[test]
fn parses_codex_primary_and_secondary_windows() {
let limits = serde_json::json!({
"primary": {"usedPercent": 10, "windowDurationMins": 300, "resetsAt": 1788853003_i64},
"secondary": {"usedPercent": 2, "windowDurationMins": 10080, "resetsAt": 1789439803_i64}
});
let windows = parse_codex_windows(&limits);
assert_eq!(windows.len(), 2);
assert_eq!(windows[0].label, "5-hour window");
assert_eq!(windows[0].percent, 10.0);
assert!(windows[0].active);
assert_eq!(windows[1].kind, "weekly_all");
assert_eq!(windows[1].label, "Weekly");
assert!(
windows[1]
.resets_at
.as_deref()
.is_some_and(|at| at.ends_with('Z'))
);
}
}