Add Codex JSON sessions and usage limits
This commit is contained in:
1 parent
0862b47f76
commit
6a0202b1b5
14 files changed
+1173
-52
No files matched your search
+158
-4
@@ -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'))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user