Skip to content

Commit 9665d21

Browse files
committed
[app-server] read rate limit API
1 parent 2287d2a commit 9665d21

File tree

18 files changed

+603
-18
lines changed

18 files changed

+603
-18
lines changed

codex-rs/Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ codex-app-server = { path = "app-server" }
5454
codex-app-server-protocol = { path = "app-server-protocol" }
5555
codex-apply-patch = { path = "apply-patch" }
5656
codex-arg0 = { path = "arg0" }
57+
codex-backend-client = { path = "backend-client" }
5758
codex-chatgpt = { path = "chatgpt" }
5859
codex-common = { path = "common" }
5960
codex-core = { path = "core" }

codex-rs/app-server-protocol/src/protocol.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use codex_protocol::parse_command::ParsedCommand;
1313
use codex_protocol::protocol::AskForApproval;
1414
use codex_protocol::protocol::EventMsg;
1515
use codex_protocol::protocol::FileChange;
16+
use codex_protocol::protocol::RateLimitSnapshot;
1617
use codex_protocol::protocol::ReviewDecision;
1718
use codex_protocol::protocol::SandboxPolicy;
1819
use codex_protocol::protocol::TurnAbortReason;
@@ -172,6 +173,12 @@ client_request_definitions! {
172173
params: ExecOneOffCommandParams,
173174
response: ExecOneOffCommandResponse,
174175
},
176+
#[serde(rename = "account/rateLimits/read")]
177+
#[ts(rename = "account/rateLimits/read")]
178+
GetAccountRateLimits {
179+
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
180+
response: GetAccountRateLimitsResponse,
181+
},
175182
}
176183

177184
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)]
@@ -406,6 +413,12 @@ pub struct ExecOneOffCommandResponse {
406413
pub stderr: String,
407414
}
408415

416+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
417+
#[serde(rename_all = "camelCase")]
418+
pub struct GetAccountRateLimitsResponse {
419+
pub rate_limits: RateLimitSnapshot,
420+
}
421+
409422
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
410423
#[serde(rename_all = "camelCase")]
411424
pub struct GetAuthStatusResponse {
@@ -940,4 +953,20 @@ mod tests {
940953
assert_eq!(payload.request_with_id(RequestId::Integer(7)), request);
941954
Ok(())
942955
}
956+
957+
#[test]
958+
fn serialize_get_account_rate_limits() -> Result<()> {
959+
let request = ClientRequest::GetAccountRateLimits {
960+
request_id: RequestId::Integer(1),
961+
params: None,
962+
};
963+
assert_eq!(
964+
json!({
965+
"method": "account/rateLimits/read",
966+
"id": 1,
967+
}),
968+
serde_json::to_value(&request)?,
969+
);
970+
Ok(())
971+
}
943972
}

codex-rs/app-server/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ anyhow = { workspace = true }
1919
codex-arg0 = { workspace = true }
2020
codex-common = { workspace = true, features = ["cli"] }
2121
codex-core = { workspace = true }
22+
codex-backend-client = { workspace = true }
2223
codex-file-search = { workspace = true }
2324
codex-login = { workspace = true }
2425
codex-protocol = { workspace = true }
2526
codex-app-server-protocol = { workspace = true }
2627
codex-utils-json-to-toml = { workspace = true }
28+
chrono = { workspace = true }
2729
serde = { workspace = true, features = ["derive"] }
2830
serde_json = { workspace = true }
2931
tokio = { workspace = true, features = [

codex-rs/app-server/src/codex_message_processor.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use codex_app_server_protocol::ApplyPatchApprovalParams;
99
use codex_app_server_protocol::ApplyPatchApprovalResponse;
1010
use codex_app_server_protocol::ArchiveConversationParams;
1111
use codex_app_server_protocol::ArchiveConversationResponse;
12+
use codex_app_server_protocol::AuthMode;
1213
use codex_app_server_protocol::AuthStatusChangeNotification;
1314
use codex_app_server_protocol::ClientRequest;
1415
use codex_app_server_protocol::ConversationSummary;
@@ -18,6 +19,7 @@ use codex_app_server_protocol::ExecOneOffCommandParams;
1819
use codex_app_server_protocol::ExecOneOffCommandResponse;
1920
use codex_app_server_protocol::FuzzyFileSearchParams;
2021
use codex_app_server_protocol::FuzzyFileSearchResponse;
22+
use codex_app_server_protocol::GetAccountRateLimitsResponse;
2123
use codex_app_server_protocol::GetUserAgentResponse;
2224
use codex_app_server_protocol::GetUserSavedConfigResponse;
2325
use codex_app_server_protocol::GitDiffToRemoteResponse;
@@ -49,6 +51,9 @@ use codex_app_server_protocol::SetDefaultModelParams;
4951
use codex_app_server_protocol::SetDefaultModelResponse;
5052
use codex_app_server_protocol::UserInfoResponse;
5153
use codex_app_server_protocol::UserSavedConfig;
54+
use codex_backend_client::Client as BackendClient;
55+
use codex_backend_client::types::RateLimitStatusPayload;
56+
use codex_backend_client::types::RateLimitWindowSnapshot;
5257
use codex_core::AuthManager;
5358
use codex_core::CodexConversation;
5459
use codex_core::ConversationManager;
@@ -87,6 +92,8 @@ use codex_protocol::ConversationId;
8792
use codex_protocol::models::ContentItem;
8893
use codex_protocol::models::ResponseItem;
8994
use codex_protocol::protocol::InputMessageKind;
95+
use codex_protocol::protocol::RateLimitSnapshot;
96+
use codex_protocol::protocol::RateLimitWindow;
9097
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
9198
use codex_utils_json_to_toml::json_to_toml;
9299
use std::collections::HashMap;
@@ -239,6 +246,12 @@ impl CodexMessageProcessor {
239246
ClientRequest::ExecOneOffCommand { request_id, params } => {
240247
self.exec_one_off_command(request_id, params).await;
241248
}
249+
ClientRequest::GetAccountRateLimits {
250+
request_id,
251+
params: _,
252+
} => {
253+
self.get_account_rate_limits(request_id).await;
254+
}
242255
}
243256
}
244257

@@ -499,6 +512,70 @@ impl CodexMessageProcessor {
499512
self.outgoing.send_response(request_id, response).await;
500513
}
501514

515+
async fn get_account_rate_limits(&self, request_id: RequestId) {
516+
match self.fetch_account_rate_limits().await {
517+
Ok(rate_limits) => {
518+
let response = GetAccountRateLimitsResponse { rate_limits };
519+
self.outgoing.send_response(request_id, response).await;
520+
}
521+
Err(error) => {
522+
self.outgoing.send_error(request_id, error).await;
523+
}
524+
}
525+
}
526+
527+
async fn fetch_account_rate_limits(&self) -> Result<RateLimitSnapshot, JSONRPCErrorError> {
528+
let Some(auth) = self.auth_manager.auth() else {
529+
return Err(JSONRPCErrorError {
530+
code: INVALID_REQUEST_ERROR_CODE,
531+
message: "codex account authentication required to read rate limits".to_string(),
532+
data: None,
533+
});
534+
};
535+
536+
if auth.mode != AuthMode::ChatGPT {
537+
return Err(JSONRPCErrorError {
538+
code: INVALID_REQUEST_ERROR_CODE,
539+
message: "chatgpt authentication required to read rate limits".to_string(),
540+
data: None,
541+
});
542+
}
543+
544+
let token = auth.get_token().await.map_err(|err| JSONRPCErrorError {
545+
code: INTERNAL_ERROR_CODE,
546+
message: format!("failed to read codex auth token: {err}"),
547+
data: None,
548+
})?;
549+
550+
let mut client =
551+
BackendClient::new(self.config.chatgpt_base_url.clone()).map_err(|err| {
552+
JSONRPCErrorError {
553+
code: INTERNAL_ERROR_CODE,
554+
message: format!("failed to construct backend client: {err}"),
555+
data: None,
556+
}
557+
})?;
558+
559+
client = client
560+
.with_user_agent(get_codex_user_agent())
561+
.with_bearer_token(token);
562+
563+
if let Some(account_id) = auth.get_account_id() {
564+
client = client.with_chatgpt_account_id(account_id);
565+
}
566+
567+
let payload = client
568+
.get_rate_limits()
569+
.await
570+
.map_err(|err| JSONRPCErrorError {
571+
code: INTERNAL_ERROR_CODE,
572+
message: format!("failed to fetch codex rate limits: {err}"),
573+
data: None,
574+
})?;
575+
576+
Ok(rate_limit_snapshot_from_payload(payload))
577+
}
578+
502579
async fn get_user_saved_config(&self, request_id: RequestId) {
503580
let toml_value = match load_config_as_toml(&self.config.codex_home).await {
504581
Ok(val) => val,
@@ -1364,6 +1441,50 @@ async fn derive_config_from_params(
13641441
Config::load_with_cli_overrides(cli_overrides, overrides).await
13651442
}
13661443

1444+
fn rate_limit_snapshot_from_payload(payload: RateLimitStatusPayload) -> RateLimitSnapshot {
1445+
let Some(details) = payload
1446+
.rate_limit
1447+
.and_then(|inner| inner.map(|boxed| *boxed))
1448+
else {
1449+
return RateLimitSnapshot {
1450+
primary: None,
1451+
secondary: None,
1452+
};
1453+
};
1454+
1455+
RateLimitSnapshot {
1456+
primary: map_rate_limit_window(details.primary_window),
1457+
secondary: map_rate_limit_window(details.secondary_window),
1458+
}
1459+
}
1460+
1461+
fn map_rate_limit_window(
1462+
window: Option<Option<Box<RateLimitWindowSnapshot>>>,
1463+
) -> Option<RateLimitWindow> {
1464+
let snapshot = match window {
1465+
Some(Some(snapshot)) => *snapshot,
1466+
_ => return None,
1467+
};
1468+
1469+
let used_percent = f64::from(snapshot.used_percent);
1470+
let window_minutes = window_minutes_from_seconds(snapshot.limit_window_seconds);
1471+
let resets_at = snapshot.reset_at;
1472+
Some(RateLimitWindow {
1473+
used_percent,
1474+
window_minutes,
1475+
resets_at,
1476+
})
1477+
}
1478+
1479+
fn window_minutes_from_seconds(seconds: i32) -> Option<u64> {
1480+
if seconds <= 0 {
1481+
return None;
1482+
}
1483+
1484+
let seconds_u64 = seconds as u64;
1485+
Some(seconds_u64.div_ceil(60))
1486+
}
1487+
13671488
async fn on_patch_approval_response(
13681489
event_id: String,
13691490
receiver: oneshot::Receiver<JsonRpcResult>,

codex-rs/app-server/tests/common/mcp_process.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,11 @@ impl McpProcess {
236236
self.send_request("getUserAgent", None).await
237237
}
238238

239+
/// Send an `account/rateLimits/read` JSON-RPC request.
240+
pub async fn send_get_account_rate_limits_request(&mut self) -> anyhow::Result<i64> {
241+
self.send_request("account/rateLimits/read", None).await
242+
}
243+
239244
/// Send a `userInfo` JSON-RPC request.
240245
pub async fn send_user_info_request(&mut self) -> anyhow::Result<i64> {
241246
self.send_request("userInfo", None).await

codex-rs/app-server/tests/suite/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod fuzzy_file_search;
77
mod interrupt;
88
mod list_resume;
99
mod login;
10+
mod rate_limits;
1011
mod send_message;
1112
mod set_default_model;
1213
mod user_agent;

0 commit comments

Comments
 (0)