use crate::{models::RateLimitBucket, settings::AppSettings}; use serde::Deserialize; use serde_json::{json, Value}; use std::{collections::BTreeMap, process::Stdio, time::Duration}; use thiserror::Error; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, process::{ChildStdout, Command}, time::timeout, }; const RPC_TIMEOUT: Duration = Duration::from_secs(20); #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x0800_0000; #[derive(Debug, Error)] pub enum CodexRpcError { #[error("failed to start Codex app-server: {0}")] Spawn(#[from] std::io::Error), #[error("Codex app-server did not expose stdio")] MissingPipe, #[error("Codex app-server timed out")] Timeout, #[error("Codex app-server closed stdout")] Closed, #[error("invalid app-server JSON: {0}")] InvalidJson(#[from] serde_json::Error), #[error("app-server returned error: {0}")] Rpc(String), #[error("app-server response was missing result")] MissingResult, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RateLimitsEnvelope { rate_limits: Option, rate_limits_by_limit_id: Option>, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RawRateLimitBucket { limit_id: String, limit_name: Option, primary: Option, secondary: Option, rate_limit_reached_type: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RawLimitWindow { used_percent: Option, window_duration_mins: Option, resets_at: Option, } pub async fn fetch_rate_limits( settings: &AppSettings, ) -> Result, CodexRpcError> { let executable = settings.resolve_codex_executable(); let mut command = Command::new(executable); command .arg("app-server") .arg("--listen") .arg("stdio://") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .kill_on_drop(true); #[cfg(windows)] command.creation_flags(CREATE_NO_WINDOW); let mut child = command.spawn()?; let mut stdin = child.stdin.take().ok_or(CodexRpcError::MissingPipe)?; let stdout = child.stdout.take().ok_or(CodexRpcError::MissingPipe)?; let mut reader = BufReader::new(stdout).lines(); write_json( &mut stdin, json!({ "jsonrpc": "2.0", "method": "initialize", "id": 0, "params": { "clientInfo": { "name": "codex_limit_monitor", "title": "Codex Limit Monitor", "version": env!("CARGO_PKG_VERSION") } } }), ) .await?; read_response(&mut reader, 0).await?; write_json( &mut stdin, json!({ "jsonrpc": "2.0", "method": "initialized", "params": {} }), ) .await?; write_json( &mut stdin, json!({ "jsonrpc": "2.0", "method": "account/rateLimits/read", "id": 1 }), ) .await?; let response = read_response(&mut reader, 1).await?; let _ = child.kill().await; let result = response .get("result") .cloned() .ok_or(CodexRpcError::MissingResult)?; parse_rate_limits(result) } async fn write_json( stdin: &mut tokio::process::ChildStdin, value: Value, ) -> Result<(), CodexRpcError> { let mut line = serde_json::to_vec(&value)?; line.push(b'\n'); stdin.write_all(&line).await?; stdin.flush().await?; Ok(()) } async fn read_response( reader: &mut tokio::io::Lines>, id: u64, ) -> Result { loop { let line = timeout(RPC_TIMEOUT, reader.next_line()) .await .map_err(|_| CodexRpcError::Timeout)? .map_err(CodexRpcError::Spawn)? .ok_or(CodexRpcError::Closed)?; let value: Value = serde_json::from_str(&line)?; if value.get("id").and_then(Value::as_u64) != Some(id) { continue; } if let Some(error) = value.get("error") { return Err(CodexRpcError::Rpc(error.to_string())); } return Ok(value); } } fn parse_rate_limits(result: Value) -> Result, CodexRpcError> { let envelope: RateLimitsEnvelope = serde_json::from_value(result)?; let mut buckets: Vec = Vec::new(); if let Some(by_id) = envelope.rate_limits_by_limit_id { for raw in by_id.into_values() { buckets.push(raw.into()); } } else if let Some(raw) = envelope.rate_limits { buckets.push(raw.into()); } buckets.sort_by(|a, b| a.limit_id.cmp(&b.limit_id)); Ok(buckets) } impl From for RateLimitBucket { fn from(raw: RawRateLimitBucket) -> Self { let primary = raw.primary.unwrap_or_default(); let secondary = raw.secondary; Self { limit_id: raw.limit_id, limit_name: raw.limit_name, used_percent: primary.used_percent.unwrap_or(0.0), window_duration_mins: primary.window_duration_mins, resets_at: primary.resets_at, secondary_used_percent: secondary.as_ref().and_then(|value| value.used_percent), secondary_resets_at: secondary.and_then(|value| value.resets_at), reached_type: raw.rate_limit_reached_type, } } } impl Default for RawLimitWindow { fn default() -> Self { Self { used_percent: Some(0.0), window_duration_mins: None, resets_at: None, } } } #[cfg(test)] mod tests { use super::*; #[test] fn parses_multi_bucket_rate_limits() { let value = json!({ "rateLimitsByLimitId": { "codex": { "limitId": "codex", "limitName": null, "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null }, "codex_other": { "limitId": "codex_other", "limitName": "codex_other", "primary": { "usedPercent": 42, "windowDurationMins": 60, "resetsAt": 1730950800 } } } }); let buckets = parse_rate_limits(value).unwrap(); assert_eq!(buckets.len(), 2); assert_eq!(buckets[0].limit_id, "codex"); assert_eq!(buckets[0].used_percent, 25.0); assert_eq!(buckets[1].limit_id, "codex_other"); assert_eq!(buckets[1].window_duration_mins, Some(60)); } }