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 authored and GitHub committed 2026-06-15 21:54:01 +00:00
1 parent af99f6a72f
commit bef99f861b
31 files changed
+1060 -84

No files matched your search

@@ -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,
}
}