mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08: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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user