feat(app-server): expose rate-limit reset credits (#28143)

## Why

Codex users can earn personal rate-limit reset credits, but app-server
clients do not currently have an API for reading or redeeming them. This
adds the backend and protocol foundation used by the `/usage` TUI flow
in #28154.

## What changed

- Extend `account/rateLimits/read` with a nullable
`rateLimitResetCredits` summary sourced from the existing usage
response.
- Add backend-client and app-server support for consuming a reset with a
caller-generated idempotency key. A UUID is recommended, and clients
reuse the same key when retrying the same logical reset.
- Return only the consume `outcome`; clients refetch
`account/rateLimits/read` for updated window state.
- Document the response field and each consume outcome, and regenerate
the JSON and TypeScript schema fixtures.
- Clarify in `AGENTS.md` that new app-server string enum values use
camelCase on the wire.
- Update the existing TUI response fixture for the expanded protocol
shape.
- Add coverage for authentication, response mapping, backend failures,
consume outcomes, and request timeout behavior.

## Validation

- `just test -p codex-app-server-protocol` — 231 passed.
- `just test -p codex-backend-client` — 14 passed.
- Focused `codex-app-server` reset-credit tests — 5 passed.
- Focused `codex-tui` protocol response fixture test — passed.
- `just fix -p codex-backend-client -p codex-app-server-protocol -p
codex-app-server` — passed.
- `just fmt` — passed.
This commit is contained in:
jay
2026-06-15 14:54:01 -07:00
committed by GitHub
Unverified
parent af99f6a72f
commit bef99f861b
31 changed files with 1060 additions and 84 deletions
@@ -1376,6 +1376,11 @@ impl MessageProcessor {
ClientRequest::GetAccountRateLimits { .. } => {
self.account_processor.get_account_rate_limits().await
}
ClientRequest::ConsumeAccountRateLimitResetCredit { params, .. } => {
self.account_processor
.consume_account_rate_limit_reset_credit(params)
.await
}
ClientRequest::GetAccountTokenUsage { .. } => {
self.account_processor.get_account_token_usage().await
}
@@ -52,6 +52,9 @@ use codex_app_server_protocol::CommandExecResizeParams;
use codex_app_server_protocol::CommandExecTerminateParams;
use codex_app_server_protocol::CommandExecWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditParams;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
use codex_app_server_protocol::ConversationGitInfo;
use codex_app_server_protocol::ConversationSummary;
use codex_app_server_protocol::DynamicToolFunctionSpec;
@@ -149,6 +152,7 @@ use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
@@ -279,6 +283,7 @@ use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
use codex_arg0::Arg0DispatchPaths;
use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType;
use codex_backend_client::Client as BackendClient;
use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode;
use codex_backend_client::TokenUsageProfile;
use codex_chatgpt::connectors;
use codex_chatgpt::workspace_settings;
@@ -401,7 +406,6 @@ use codex_protocol::protocol::GitInfo as CoreGitInfo;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RealtimeVoicesList;
use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::ReviewDelivery as CoreReviewDelivery;
@@ -1,5 +1,7 @@
use super::*;
mod rate_limit_resets;
// Duration before a browser ChatGPT login attempt is abandoned.
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
@@ -851,19 +853,64 @@ impl AccountRequestProcessor {
async fn get_account_rate_limits_response(
&self,
) -> Result<GetAccountRateLimitsResponse, JSONRPCErrorError> {
self.fetch_account_rate_limits()
let Some(auth) = self.auth_manager.auth().await else {
return Err(invalid_request(
"codex account authentication required to read rate limits",
));
};
if !auth.uses_codex_backend() {
return Err(invalid_request(
"chatgpt authentication required to read rate limits",
));
}
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
let response = client
.get_rate_limits_with_reset_credits()
.await
.map(
|(rate_limits, rate_limits_by_limit_id)| GetAccountRateLimitsResponse {
rate_limits: rate_limits.into(),
rate_limits_by_limit_id: Some(
rate_limits_by_limit_id
.into_iter()
.map(|(limit_id, snapshot)| (limit_id, snapshot.into()))
.collect(),
),
},
)
.map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;
if response.rate_limits.is_empty() {
return Err(internal_error(
"failed to fetch codex rate limits: no snapshots returned",
));
}
let rate_limits_by_limit_id: HashMap<_, _> = response
.rate_limits
.iter()
.cloned()
.map(|snapshot| {
let limit_id = snapshot
.limit_id
.clone()
.unwrap_or_else(|| "codex".to_string());
(limit_id, snapshot)
})
.collect();
let rate_limits = response
.rate_limits
.iter()
.find(|snapshot| snapshot.limit_id.as_deref() == Some("codex"))
.cloned()
.unwrap_or_else(|| response.rate_limits[0].clone());
Ok(GetAccountRateLimitsResponse {
rate_limits: rate_limits.into(),
rate_limits_by_limit_id: Some(
rate_limits_by_limit_id
.into_iter()
.map(|(limit_id, snapshot)| (limit_id, snapshot.into()))
.collect(),
),
rate_limit_reset_credits: response.rate_limit_reset_credits.map(|summary| {
RateLimitResetCreditsSummary {
available_count: summary.available_count,
}
}),
})
}
async fn get_account_token_usage_response(
@@ -963,61 +1010,6 @@ impl AccountRequestProcessor {
AddCreditsNudgeCreditType::UsageLimit => BackendAddCreditsNudgeCreditType::UsageLimit,
}
}
async fn fetch_account_rate_limits(
&self,
) -> Result<
(
CoreRateLimitSnapshot,
HashMap<String, CoreRateLimitSnapshot>,
),
JSONRPCErrorError,
> {
let Some(auth) = self.auth_manager.auth().await else {
return Err(invalid_request(
"codex account authentication required to read rate limits",
));
};
if !auth.uses_codex_backend() {
return Err(invalid_request(
"chatgpt authentication required to read rate limits",
));
}
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
let snapshots = client
.get_rate_limits_many()
.await
.map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;
if snapshots.is_empty() {
return Err(internal_error(
"failed to fetch codex rate limits: no snapshots returned",
));
}
let rate_limits_by_limit_id: HashMap<String, CoreRateLimitSnapshot> = snapshots
.iter()
.cloned()
.map(|snapshot| {
let limit_id = snapshot
.limit_id
.clone()
.unwrap_or_else(|| "codex".to_string());
(limit_id, snapshot)
})
.collect();
let primary = snapshots
.iter()
.find(|snapshot| snapshot.limit_id.as_deref() == Some("codex"))
.cloned()
.unwrap_or_else(|| snapshots[0].clone());
Ok((primary, rate_limits_by_limit_id))
}
}
#[cfg(test)]
@@ -0,0 +1,66 @@
use super::*;
const RATE_LIMIT_RESET_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
#[cfg(debug_assertions)]
const RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR: &str =
"CODEX_TEST_RATE_LIMIT_RESET_REQUEST_TIMEOUT_MS";
impl AccountRequestProcessor {
pub(crate) async fn consume_account_rate_limit_reset_credit(
&self,
params: ConsumeAccountRateLimitResetCreditParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
if params.idempotency_key.is_empty() {
return Err(invalid_request("idempotencyKey must not be empty"));
}
let client = self.rate_limit_reset_backend_client().await?;
let request_timeout = RATE_LIMIT_RESET_REQUEST_TIMEOUT;
#[cfg(debug_assertions)]
let request_timeout = std::env::var(RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR)
.ok()
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or(request_timeout);
let response = tokio::time::timeout(
request_timeout,
client.consume_rate_limit_reset_credit(&params.idempotency_key),
)
.await
.map_err(|_| internal_error("rate limit reset consume timed out"))?
.map_err(|err| internal_error(format!("failed to consume rate limit reset: {err}")))?;
let outcome = match response.code {
BackendConsumeRateLimitResetCreditCode::Reset => {
ConsumeAccountRateLimitResetCreditOutcome::Reset
}
BackendConsumeRateLimitResetCreditCode::NothingToReset => {
ConsumeAccountRateLimitResetCreditOutcome::NothingToReset
}
BackendConsumeRateLimitResetCreditCode::NoCredit => {
ConsumeAccountRateLimitResetCreditOutcome::NoCredit
}
BackendConsumeRateLimitResetCreditCode::AlreadyRedeemed => {
ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed
}
};
Ok(Some(
ConsumeAccountRateLimitResetCreditResponse { outcome }.into(),
))
}
async fn rate_limit_reset_backend_client(&self) -> Result<BackendClient, JSONRPCErrorError> {
let Some(auth) = self.auth_manager.auth().await else {
return Err(invalid_request(
"codex account authentication required for rate limit reset credits",
));
};
if !auth.uses_codex_backend() {
return Err(invalid_request(
"chatgpt authentication required for rate limit reset credits",
));
}
BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))
}
}