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 21:54:01 +00:00
committed by GitHub
parent af99f6a72f
commit bef99f861b
31 changed files with 1060 additions and 84 deletions
+3 -8
View File
@@ -28,6 +28,8 @@ use serde::Serialize;
use serde::de::DeserializeOwned;
use std::fmt;
mod rate_limit_resets;
#[derive(Debug)]
pub enum RequestError {
UnexpectedStatus {
@@ -294,14 +296,7 @@ impl Client {
}
pub async fn get_rate_limits_many(&self) -> Result<Vec<RateLimitSnapshot>> {
let url = match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/usage", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/usage", self.base_url),
};
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
let payload: RateLimitStatusPayload = self.decode_json(&url, &ct, &body)?;
Ok(Self::rate_limit_snapshots_from_payload(payload))
Ok(self.get_rate_limits_with_reset_credits().await?.rate_limits)
}
pub async fn get_accounts_check(&self) -> Result<AccountsCheckResponse> {
@@ -0,0 +1,73 @@
//! Backend client operations for reading available rate-limit reset credits and consuming one.
use super::Client;
use super::PathStyle;
use crate::types::ConsumeRateLimitResetCreditResponse;
use crate::types::RateLimitStatusWithResetCredits;
use crate::types::RateLimitsWithResetCredits;
use anyhow::Result;
use reqwest::header::CONTENT_TYPE;
use reqwest::header::HeaderValue;
use serde::Serialize;
#[derive(Serialize)]
struct ConsumeRateLimitResetCreditRequest<'a> {
redeem_request_id: &'a str,
}
impl Client {
pub async fn get_rate_limits_with_reset_credits(&self) -> Result<RateLimitsWithResetCredits> {
let payload = self.get_rate_limit_status().await?;
Ok(RateLimitsWithResetCredits {
rate_limits: Self::rate_limit_snapshots_from_payload(payload.rate_limits),
rate_limit_reset_credits: payload.rate_limit_reset_credits,
})
}
pub(super) async fn get_rate_limit_status(&self) -> Result<RateLimitStatusWithResetCredits> {
let url = self.rate_limit_status_url();
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json(&url, &ct, &body)
}
pub async fn consume_rate_limit_reset_credit(
&self,
redeem_request_id: &str,
) -> Result<ConsumeRateLimitResetCreditResponse> {
let url = self.consume_rate_limit_reset_credit_url();
let req = self
.http
.post(&url)
.headers(self.headers())
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&ConsumeRateLimitResetCreditRequest { redeem_request_id });
let (body, ct) = self.exec_request(req, "POST", &url).await?;
self.decode_json(&url, &ct, &body)
}
fn rate_limit_status_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/usage", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/usage", self.base_url),
}
}
fn consume_rate_limit_reset_credit_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => {
format!(
"{}/api/codex/rate-limit-reset-credits/consume",
self.base_url
)
}
PathStyle::ChatGptApi => {
format!("{}/wham/rate-limit-reset-credits/consume", self.base_url)
}
}
}
}
#[cfg(test)]
#[path = "rate_limit_resets_tests.rs"]
mod tests;
@@ -0,0 +1,71 @@
use super::*;
use crate::types::ConsumeRateLimitResetCreditCode;
use crate::types::RateLimitResetCreditsSummary;
use pretty_assertions::assert_eq;
#[test]
fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
assert_eq!(
test_client("https://example.test", PathStyle::CodexApi).rate_limit_status_url(),
"https://example.test/api/codex/usage"
);
assert_eq!(
test_client("https://example.test", PathStyle::CodexApi)
.consume_rate_limit_reset_credit_url(),
"https://example.test/api/codex/rate-limit-reset-credits/consume"
);
assert_eq!(
test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi)
.rate_limit_status_url(),
"https://chatgpt.com/backend-api/wham/usage"
);
assert_eq!(
test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi)
.consume_rate_limit_reset_credit_url(),
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"
);
assert_eq!(
serde_json::to_value(ConsumeRateLimitResetCreditRequest {
redeem_request_id: "redeem-123",
})
.unwrap(),
serde_json::json!({ "redeem_request_id": "redeem-123" })
);
let status: RateLimitStatusWithResetCredits = serde_json::from_value(serde_json::json!({
"plan_type": "plus",
"rate_limit_reset_credits": { "available_count": 3 }
}))
.unwrap();
assert_eq!(
status.rate_limit_reset_credits,
Some(RateLimitResetCreditsSummary { available_count: 3 })
);
let response: ConsumeRateLimitResetCreditResponse = serde_json::from_value(serde_json::json!({
"code": "reset",
"credit": { "id": "ignored-by-cli" },
"windows_reset": 2
}))
.unwrap();
assert_eq!(
response,
ConsumeRateLimitResetCreditResponse {
code: ConsumeRateLimitResetCreditCode::Reset,
windows_reset: 2,
}
);
}
fn test_client(base_url: &str, path_style: PathStyle) -> Client {
Client {
base_url: base_url.to_string(),
http: reqwest::Client::new(),
auth_provider: codex_model_provider::unauthenticated_auth_provider(),
user_agent: None,
chatgpt_account_id: None,
chatgpt_account_is_fedramp: false,
path_style,
}
}
+4
View File
@@ -9,10 +9,14 @@ pub use types::AccountsCheckResponse;
pub use types::CodeTaskDetailsResponse;
pub use types::CodeTaskDetailsResponseExt;
pub use types::ConfigBundleResponse;
pub use types::ConsumeRateLimitResetCreditCode;
pub use types::ConsumeRateLimitResetCreditResponse;
pub use types::DeliveredConfigToml;
pub use types::DeliveredRequirementsToml;
pub use types::DeliveredTomlFragment;
pub use types::PaginatedListTaskListItem;
pub use types::RateLimitResetCreditsSummary;
pub use types::RateLimitsWithResetCredits;
pub use types::TaskListItem;
pub use types::TokenUsageProfile;
pub use types::TokenUsageProfileDailyBucket;
+35
View File
@@ -12,11 +12,46 @@ pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot;
pub use codex_backend_openapi_models::models::SpendControlLimitDetails;
pub use codex_backend_openapi_models::models::TaskListItem;
use codex_protocol::protocol::RateLimitSnapshot;
use serde::Deserialize;
use serde::de::Deserializer;
use serde_json::Value;
use std::collections::HashMap;
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct RateLimitResetCreditsSummary {
pub available_count: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RateLimitsWithResetCredits {
pub rate_limits: Vec<RateLimitSnapshot>,
pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub(crate) struct RateLimitStatusWithResetCredits {
#[serde(flatten)]
pub rate_limits: RateLimitStatusPayload,
pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConsumeRateLimitResetCreditCode {
Reset,
NothingToReset,
NoCredit,
AlreadyRedeemed,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct ConsumeRateLimitResetCreditResponse {
pub code: ConsumeRateLimitResetCreditCode,
#[serde(default)]
pub windows_reset: i64,
}
#[derive(Clone, Debug)]
pub struct AccountsCheckResponse {
pub accounts: Vec<AccountEntry>,