mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
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:
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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user