Extend rate limit reads with usage capabilities (#42358)

## What changed

- Accept optional `account/rateLimits/read` capabilities for Luna Reserve support
  and lightweight background polling while preserving omitted and `null` params.
- Forward the Luna Reserve capability only for eligible ChatGPT sessions, and let
  lightweight polls skip the separate reset-credit detail request.
- Return the backend's `ordinaryUsageAllowed` decision only when its account and
  user identity match the active session.

## Testing

- Cover capability defaults, eligible and restricted authentication modes,
  lightweight polling, identity filtering, and backend permission handling.

GitOrigin-RevId: f407a3ae1e70e411fdf245f072a16e453f59cd87
This commit is contained in:
dhruvgupta-oai
2026-09-02 21:01:04 +00:00
committed by copyberry
parent 0227158fd5
commit 577a4fcd06
26 changed files with 459 additions and 28 deletions
+22 -1
View File
@@ -1524,6 +1524,20 @@
},
"type": "object"
},
"GetAccountRateLimitsParams": {
"description": "Usage-read capabilities of the requesting client, never inferred from its experiment arm.",
"properties": {
"excludeResetCreditDetails": {
"description": "Skip the separate reset-credit detail lookup for background usage polls. The usage response still includes the available count; omitted/false preserves detailed reads.",
"type": "boolean"
},
"supportsLunaReserve": {
"description": "The client supports automatic Luna Reserve fallback. For eligible ChatGPT CLI users, allow the backend to record experiment exposure after ordinary usage is blocked.",
"type": "boolean"
}
},
"type": "object"
},
"GetAccountTokenUsageParams": {
"properties": {
"threadId": {
@@ -7650,7 +7664,14 @@
"type": "string"
},
"params": {
"type": "null"
"anyOf": [
{
"$ref": "#/definitions/GetAccountRateLimitsParams"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -2111,7 +2111,14 @@
"type": "string"
},
"params": {
"type": "null"
"anyOf": [
{
"$ref": "#/definitions/v2/GetAccountRateLimitsParams"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -12258,6 +12265,20 @@
"title": "GetAccountParams",
"type": "object"
},
"GetAccountRateLimitsParams": {
"description": "Usage-read capabilities of the requesting client, never inferred from its experiment arm.",
"properties": {
"excludeResetCreditDetails": {
"description": "Skip the separate reset-credit detail lookup for background usage polls. The usage response still includes the available count; omitted/false preserves detailed reads.",
"type": "boolean"
},
"supportsLunaReserve": {
"description": "The client supports automatic Luna Reserve fallback. For eligible ChatGPT CLI users, allow the backend to record experiment exposure after ordinary usage is blocked.",
"type": "boolean"
}
},
"type": "object"
},
"GetAccountRateLimitsResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -12268,6 +12289,13 @@
"null"
]
},
"ordinaryUsageAllowed": {
"description": "Backend permission for ordinary included usage, validated against the active account. Null means unavailable; clients must not infer recovery from percentages or reset times.",
"type": [
"boolean",
"null"
]
},
"rateLimitResetCredits": {
"anyOf": [
{
@@ -15497,6 +15525,18 @@
],
"type": "string"
},
"NullableGetAccountRateLimitsParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"$ref": "#/definitions/v2/GetAccountRateLimitsParams"
},
{
"type": "null"
}
],
"title": "Nullable_GetAccountRateLimitsParams"
},
"NullableGetAccountTokenUsageParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
@@ -3601,7 +3601,14 @@
"type": "string"
},
"params": {
"type": "null"
"anyOf": [
{
"$ref": "#/definitions/GetAccountRateLimitsParams"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -8269,6 +8276,20 @@
"title": "GetAccountParams",
"type": "object"
},
"GetAccountRateLimitsParams": {
"description": "Usage-read capabilities of the requesting client, never inferred from its experiment arm.",
"properties": {
"excludeResetCreditDetails": {
"description": "Skip the separate reset-credit detail lookup for background usage polls. The usage response still includes the available count; omitted/false preserves detailed reads.",
"type": "boolean"
},
"supportsLunaReserve": {
"description": "The client supports automatic Luna Reserve fallback. For eligible ChatGPT CLI users, allow the backend to record experiment exposure after ordinary usage is blocked.",
"type": "boolean"
}
},
"type": "object"
},
"GetAccountRateLimitsResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -8279,6 +8300,13 @@
"null"
]
},
"ordinaryUsageAllowed": {
"description": "Backend permission for ordinary included usage, validated against the active account. Null means unavailable; clients must not infer recovery from percentages or reset times.",
"type": [
"boolean",
"null"
]
},
"rateLimitResetCredits": {
"anyOf": [
{
@@ -11569,6 +11597,18 @@
],
"type": "string"
},
"NullableGetAccountRateLimitsParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"$ref": "#/definitions/GetAccountRateLimitsParams"
},
{
"type": "null"
}
],
"title": "Nullable_GetAccountRateLimitsParams"
},
"NullableGetAccountTokenUsageParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
@@ -284,6 +284,13 @@
"null"
]
},
"ordinaryUsageAllowed": {
"description": "Backend permission for ordinary included usage, validated against the active account. Null means unavailable; clients must not infer recovery from percentages or reset times.",
"type": [
"boolean",
"null"
]
},
"rateLimitResetCredits": {
"anyOf": [
{
@@ -0,0 +1,28 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
{
"$ref": "#/definitions/GetAccountRateLimitsParams"
},
{
"type": "null"
}
],
"definitions": {
"GetAccountRateLimitsParams": {
"description": "Usage-read capabilities of the requesting client, never inferred from its experiment arm.",
"properties": {
"excludeResetCreditDetails": {
"description": "Skip the separate reset-credit detail lookup for background usage polls. The usage response still includes the available count; omitted/false preserves detailed reads.",
"type": "boolean"
},
"supportsLunaReserve": {
"description": "The client supports automatic Luna Reserve fallback. For eligible ChatGPT CLI users, allow the backend to record experiment exposure after ordinary usage is blocked.",
"type": "boolean"
}
},
"type": "object"
}
},
"title": "Nullable_GetAccountRateLimitsParams"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Usage-read capabilities of the requesting client, never inferred from its experiment arm.
*/
export type GetAccountRateLimitsParams = {
/**
* The client supports automatic Luna Reserve fallback. For eligible ChatGPT CLI users,
* allow the backend to record experiment exposure after ordinary usage is blocked.
*/
supportsLunaReserve?: boolean,
/**
* Skip the separate reset-credit detail lookup for background usage polls. The usage
* response still includes the available count; omitted/false preserves detailed reads.
*/
excludeResetCreditDetails?: boolean, };
@@ -6,6 +6,11 @@ import type { RateLimitResetCreditsSummary } from "./RateLimitResetCreditsSummar
import type { RateLimitSnapshot } from "./RateLimitSnapshot";
export type GetAccountRateLimitsResponse = {
/**
* Backend permission for ordinary included usage, validated against the active account.
* Null means unavailable; clients must not infer recovery from percentages or reset times.
*/
ordinaryUsageAllowed: boolean | null,
/**
* Backward-compatible single-bucket view; mirrors the historical payload.
*/
@@ -192,6 +192,7 @@ export type { FsWatchResponse } from "./FsWatchResponse";
export type { FsWriteFileParams } from "./FsWriteFileParams";
export type { FsWriteFileResponse } from "./FsWriteFileResponse";
export type { GetAccountParams } from "./GetAccountParams";
export type { GetAccountRateLimitsParams } from "./GetAccountRateLimitsParams";
export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse";
export type { GetAccountResponse } from "./GetAccountResponse";
export type { GetAccountTokenUsageParams } from "./GetAccountTokenUsageParams";
+15 -6
View File
@@ -2154,6 +2154,11 @@ mod tests {
"params?: GetAccountTokenUsageParams | undefined, }"
);
assert!(client_request_ts.contains(LEGACY_ACCOUNT_USAGE_REQUEST));
const LEGACY_ACCOUNT_RATE_LIMITS_REQUEST: &str = concat!(
"{ \"method\": \"account/rateLimits/read\", id: RequestId, ",
"params?: GetAccountRateLimitsParams | undefined, }"
);
assert!(client_request_ts.contains(LEGACY_ACCOUNT_RATE_LIMITS_REQUEST));
let account_usage_response_ts = std::str::from_utf8(
fixture_tree
.get(Path::new("v2/GetAccountTokenUsageResponse.ts"))
@@ -2232,12 +2237,16 @@ mod tests {
});
let contents = std::str::from_utf8(contents)?;
// The stable usage RPC originally required `params: undefined`. Keep that exact
// legacy value accepted while extending the same method with optional thread params.
let legacy_account_usage_undefined = path == Path::new("ClientRequest.ts")
&& contents.matches("| undefined").count() == 1
&& contents.contains(LEGACY_ACCOUNT_USAGE_REQUEST);
if contents.contains("| undefined") && !legacy_account_usage_undefined {
// Both stable usage RPCs originally required `params: undefined`. Preserve that
// source compatibility only for these exact envelopes, not arbitrary new fields.
let checked_contents = if path == Path::new("ClientRequest.ts") {
contents
.replace(LEGACY_ACCOUNT_USAGE_REQUEST, "")
.replace(LEGACY_ACCOUNT_RATE_LIMITS_REQUEST, "")
} else {
contents.to_owned()
};
if checked_contents.contains("| undefined") {
undefined_offenders.push(path.clone());
}
@@ -493,6 +493,16 @@ enum GetAccountTokenUsageParamsTypeScript {
Undefined,
}
/// Preserve omitted/undefined params while exporting the new usage capability type.
#[allow(dead_code)]
#[derive(TS)]
#[ts(untagged)]
enum GetAccountRateLimitsParamsTypeScript {
Params(v2::GetAccountRateLimitsParams),
#[ts(type = "undefined")]
Undefined,
}
client_request_definitions! {
Initialize => "initialize" {
params: v1::InitializeParams,
@@ -1232,7 +1242,7 @@ client_request_definitions! {
},
GetAccountRateLimits => "account/rateLimits/read" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
params: #[ts(optional, as = "Option<GetAccountRateLimitsParamsTypeScript>", inline)] #[serde(default, skip_serializing_if = "Option::is_none")] v2::NullableGetAccountRateLimitsParams,
serialization: None,
response: v2::GetAccountRateLimitsResponse,
},
@@ -308,10 +308,30 @@ impl fmt::Debug for ChatgptAuthTokensRefreshResponse {
}
}
/// Usage-read capabilities of the requesting client, never inferred from its experiment arm.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountRateLimitsParams {
/// The client supports automatic Luna Reserve fallback. For eligible ChatGPT CLI users,
/// allow the backend to record experiment exposure after ordinary usage is blocked.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub supports_luna_reserve: bool,
/// Skip the separate reset-credit detail lookup for background usage polls. The usage
/// response still includes the available count; omitted/false preserves detailed reads.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub exclude_reset_credit_details: bool,
}
pub type NullableGetAccountRateLimitsParams = Option<GetAccountRateLimitsParams>;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountRateLimitsResponse {
/// Backend permission for ordinary included usage, validated against the active account.
/// Null means unavailable; clients must not infer recovery from percentages or reset times.
pub ordinary_usage_allowed: Option<bool>,
/// Backward-compatible single-bucket view; mirrors the historical payload.
pub rate_limits: RateLimitSnapshot,
/// Multi-bucket view keyed by metered `limit_id` (for example, `codex`).
+15
View File
@@ -2750,11 +2750,25 @@ Codex-managed credentials are removed; AWS profiles, environment credentials, an
### 7) Rate limits (ChatGPT)
Clients that implement automatic Luna Reserve fallback may send
`"params": { "supportsLunaReserve": true }` on `account/rateLimits/read`. For eligible
ChatGPT CLI users, this opts into experiment exposure only after the backend reports
ordinary included usage blocked, for both control and treatment. It does not grant
Reserve access. Omitted params preserve non-exposing reads; API-key, PAT, and FedRAMP
sessions do not opt in. Clients connecting to older app servers that reject object
params should retry without params.
Background polls may also send `"excludeResetCreditDetails": true` to avoid the
separate reset-credit detail lookup. The usage response still supplies the available
count. Startup and user-requested usage/reset reads should omit this flag so credit
details remain available; older servers ignore it and keep their existing behavior.
```json
{ "method": "account/rateLimits/read", "id": 7 }
{
"id": 7,
"result": {
"ordinaryUsageAllowed": true,
"rateLimits": {
"primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 },
"secondary": null,
@@ -2787,6 +2801,7 @@ Field notes:
- `rateLimitReachedType` identifies the backend-classified limit state when one has been reached.
- `individualLimit` describes the effective monthly credit limit when available. In an `account/rateLimits/read` response, `null` means no monthly limit is available. In a sparse `account/rateLimits/updated` notification, nullable account metadata may be unavailable and does not clear a previously observed value.
- `accountId` identifies the account in the usage snapshot when the backend supplies it.
- `ordinaryUsageAllowed` is the backend decision for ordinary included usage, validated against the authenticated account and user. It is `null` for unavailable or mismatched identity data. A CLI task that automatically entered Luna Reserve can restore its previous model when a validated read allows included usage or reports usable credits, no backend banner or hard stop remains, and the user has not manually changed models. Percentages, reset timestamps, and sparse notifications do not authorize this transition.
- `rateLimitUpsell` carries the optional backend-owned `rate_limit_upsell` object from the same usage request, preserving its nested snake_case fields. The backend controls eligibility; clients do not evaluate an experiment or issue another request for it. A missing, null, or unsupported banner leaves the existing client UI in place. Banners whose account or user does not match the authenticated identity are omitted. Sparse notifications do not clear this snapshot-only field.
- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`.
- `rateLimitResetCredits.credits` is `null` when only the count is available. An empty array means details were fetched and no available credits were returned.
+2 -2
View File
@@ -1592,8 +1592,8 @@ impl MessageProcessor {
ClientRequest::GetAuthStatus { params, .. } => {
self.account_processor.get_auth_status(params).await
}
ClientRequest::GetAccountRateLimits { .. } => {
self.account_processor.get_account_rate_limits().await
ClientRequest::GetAccountRateLimits { params, .. } => {
self.account_processor.get_account_rate_limits(params).await
}
ClientRequest::ConsumeAccountRateLimitResetCredit { params, .. } => {
self.account_processor
@@ -7,6 +7,7 @@ use crate::auth_mode::auth_mode_to_api;
use crate::external_auth::ExternalAuthBridge;
use chrono::DateTime;
use codex_app_server_protocol::DesktopOnboardingEntrypoint;
use codex_app_server_protocol::GetAccountRateLimitsParams;
use codex_login::LoginOnboardingEntrypoint;
use codex_login::login_with_bedrock_access_keys;
use codex_model_provider::is_supported_amazon_bedrock_region;
@@ -154,8 +155,9 @@ impl AccountRequestProcessor {
pub(crate) async fn get_account_rate_limits(
&self,
params: Option<GetAccountRateLimitsParams>,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.get_account_rate_limits_response()
self.get_account_rate_limits_response(params.unwrap_or_default())
.await
.map(|response| Some(response.into()))
}
@@ -1130,6 +1132,7 @@ impl AccountRequestProcessor {
async fn get_account_rate_limits_response(
&self,
params: GetAccountRateLimitsParams,
) -> Result<GetAccountRateLimitsResponse, JSONRPCErrorError> {
let Some(auth) = self.auth_manager.auth().await else {
return Err(invalid_request(
@@ -1149,10 +1152,23 @@ impl AccountRequestProcessor {
self.config.http_client_factory(),
);
let (response, detailed_rate_limit_reset_credits) = tokio::join!(
client.get_rate_limits_with_reset_credits(),
Self::detailed_rate_limit_reset_credits(&client),
);
let usage_request = async {
if params.supports_luna_reserve
&& auth.auth_mode() == codex_protocol::auth::AuthMode::Chatgpt
&& !auth.is_fedramp_account()
{
client.get_rate_limits_with_luna_reserve().await
} else {
client.get_rate_limits_with_reset_credits().await
}
};
let (response, detailed_rate_limit_reset_credits) = tokio::join!(usage_request, async {
if params.exclude_reset_credit_details {
None
} else {
Self::detailed_rate_limit_reset_credits(&client).await
}
},);
let response = response
.map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;
if response.rate_limits.is_empty() {
@@ -1191,15 +1207,19 @@ impl AccountRequestProcessor {
// Match desktop's account readiness check before exposing account-bound CTA content.
// Normal rate limits remain available when older backends omit identity or banner data.
let rate_limit_upsell = response.rate_limit_upsell.filter(|_| {
!auth.is_fedramp_account()
&& response.account_id.is_some()
&& response.account_id == auth.get_account_id()
&& response.user_id.is_some()
&& response.user_id == auth.get_chatgpt_user_id()
});
let matches_active_account = !auth.is_fedramp_account()
&& response.account_id.is_some()
&& response.account_id == auth.get_account_id()
&& response.user_id.is_some()
&& response.user_id == auth.get_chatgpt_user_id();
let rate_limit_upsell = response
.rate_limit_upsell
.filter(|_| matches_active_account);
Ok(GetAccountRateLimitsResponse {
ordinary_usage_allowed: response
.ordinary_usage_allowed
.filter(|_| matches_active_account),
rate_limits: rate_limits.into(),
rate_limits_by_limit_id: Some(
rate_limits_by_limit_id
@@ -0,0 +1,127 @@
//! Exercise usage capability opt-in and lightweight polling through public JSON-RPC requests.
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::ChatGptIdTokenClaims;
use app_test_support::TestAppServer;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_config::types::AuthCredentialsStoreMode;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use tempfile::TempDir;
use test_case::test_case;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[derive(Clone, Copy, PartialEq)]
enum AuthKind {
Chatgpt,
Fedramp,
Pat,
}
#[test_case(AuthKind::Chatgpt, /*params*/ None, /*expect_header*/ false; "omitted_params")]
#[test_case(AuthKind::Chatgpt, Some(Value::Null), /*expect_header*/ false; "null_params")]
#[test_case(AuthKind::Chatgpt, Some(json!({})), /*expect_header*/ false; "default_capability")]
#[test_case(AuthKind::Chatgpt, Some(json!({"supportsLunaReserve": false})), /*expect_header*/ false; "disabled_capability")]
#[test_case(AuthKind::Chatgpt, Some(json!({"supportsLunaReserve": true})), /*expect_header*/ true; "eligible_client")]
#[test_case(AuthKind::Fedramp, Some(json!({"supportsLunaReserve": true})), /*expect_header*/ false; "restricted_account")]
#[test_case(AuthKind::Pat, Some(json!({"supportsLunaReserve": true})), /*expect_header*/ false; "personal_access_token")]
#[test_case(AuthKind::Chatgpt, Some(json!({"supportsLunaReserve": true, "excludeResetCreditDetails": true})), /*expect_header*/ true; "lightweight_poll")]
#[tokio::test]
async fn luna_reserve_usage_capability(
auth_kind: AuthKind,
params: Option<Value>,
expect_header: bool,
) -> Result<()> {
let home = TempDir::new()?;
let backend = MockServer::start().await;
let backend_url = backend.uri();
std::fs::write(
home.path().join("config.toml"),
format!("chatgpt_base_url = \"{backend_url}\"\n"),
)?;
if auth_kind != AuthKind::Pat {
let mut claims = ChatGptIdTokenClaims::new().chatgpt_user_id("user-a");
claims.chatgpt_account_is_fedramp = auth_kind == AuthKind::Fedramp;
write_chatgpt_auth(
home.path(),
ChatGptAuthFixture::new("test-token")
.account_id("account-a")
.claims(claims),
AuthCredentialsStoreMode::File,
)?;
}
Mock::given(method("GET"))
.and(path("/v1/user-auth-credential/whoami"))
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
"email": null, "chatgpt_user_id": "user-a",
"chatgpt_account_id": "account-a", "chatgpt_plan_type": "pro",
"chatgpt_account_is_fedramp": false
})))
.mount(&backend)
.await;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
"account_id": "account-a", "user_id": "user-a", "plan_type": "pro",
"rate_limit": {"allowed": false, "limit_reached": true},
"rate_limit_reset_credits": {"available_count": 2}
})))
.expect(/*r*/ 1)
.mount(&backend)
.await;
let lightweight = params
.as_ref()
.is_some_and(|params| params["excludeResetCreditDetails"] == true);
Mock::given(method("GET"))
.and(path("/api/codex/rate-limit-reset-credits"))
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
"available_count": 2, "credits": []
})))
.expect(if lightweight { 0 } else { 1 })
.mount(&backend)
.await;
let mut app = TestAppServer::builder()
.with_codex_home(home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(
"CODEX_ACCESS_TOKEN",
(auth_kind == AuthKind::Pat).then_some("at-test-token"),
),
("CODEX_AUTHAPI_BASE_URL", Some(backend_url.as_str())),
])
.build_initialized()
.await?;
let request = app.send_request("account/rateLimits/read", params).await?;
let response: GetAccountRateLimitsResponse = app.read_response(request).await?;
assert_eq!(
response.rate_limit_reset_credits,
Some(RateLimitResetCreditsSummary {
available_count: 2,
credits: (!lightweight).then(Vec::new),
})
);
let requests = backend
.received_requests()
.await
.expect("recorded backend requests");
let usage = requests
.iter()
.find(|request| request.url.path() == "/api/codex/usage")
.expect("usage request");
assert_eq!(
usage.headers.contains_key("x-openai-codex-luna-reserve"),
expect_header
);
backend.verify().await;
Ok(())
}
@@ -44,6 +44,7 @@ mod hooks_list;
mod host_skills;
mod imagegen_extension;
mod initialize;
mod luna_reserve;
mod marketplace_add;
mod marketplace_remove;
mod marketplace_upgrade;
@@ -249,6 +249,7 @@ async fn get_account_rate_limits_returns_snapshot(
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
let expected = GetAccountRateLimitsResponse {
ordinary_usage_allowed: Some(true),
account_id: Some("account-123".to_string()),
rate_limit_upsell: Some(banner),
rate_limits: RateLimitSnapshot {
@@ -427,6 +428,7 @@ async fn get_account_rate_limits_filters_banner_by_identity(
"primary": {"usedPercent": 42, "windowDurationMins": 60, "resetsAt": 2000000000}
});
let expected: GetAccountRateLimitsResponse = serde_json::from_value(json!({
"ordinaryUsageAllowed": if permitted { Some(true) } else { None },
"accountId": account, "rateLimitUpsell": if permitted { Some(banner) } else { None },
"rateLimits": snapshot, "rateLimitsByLimitId": {"codex": snapshot},
"rateLimitResetCredits": {"availableCount": 0, "credits": []}
@@ -21,9 +21,30 @@ struct ConsumeRateLimitResetCreditRequest<'a> {
impl Client {
pub async fn get_rate_limits_with_reset_credits(&self) -> Result<RateLimitsWithResetCredits> {
let payload = self.get_rate_limit_status().await?;
self.get_rate_limits_for_usage(/*supports_luna_reserve*/ false)
.await
}
/// Opt in only for clients that can apply Reserve, not for passive account usage readers.
pub async fn get_rate_limits_with_luna_reserve(&self) -> Result<RateLimitsWithResetCredits> {
self.get_rate_limits_for_usage(/*supports_luna_reserve*/ true)
.await
}
async fn get_rate_limits_for_usage(
&self,
supports_luna_reserve: bool,
) -> Result<RateLimitsWithResetCredits> {
let payload = self.get_rate_limit_status(supports_luna_reserve).await?;
let ordinary_usage_allowed = payload
.rate_limits
.rate_limit
.as_ref()
.and_then(|limit| limit.as_deref())
.map(|limit| limit.allowed);
Ok(RateLimitsWithResetCredits {
rate_limits: Self::rate_limit_snapshots_from_payload(payload.rate_limits),
ordinary_usage_allowed,
rate_limit_reset_credits: payload.rate_limit_reset_credits,
account_id: payload.account_id,
user_id: payload.user_id,
@@ -31,9 +52,15 @@ impl Client {
})
}
pub(super) async fn get_rate_limit_status(&self) -> Result<RateLimitStatusWithResetCredits> {
async fn get_rate_limit_status(
&self,
supports_luna_reserve: bool,
) -> Result<RateLimitStatusWithResetCredits> {
let url = self.rate_limit_status_url();
let req = self.request(Method::GET, &url).headers(self.headers());
let mut req = self.request(Method::GET, &url).headers(self.headers());
if supports_luna_reserve {
req = req.header("x-openai-codex-luna-reserve", HeaderValue::from_static("1"));
}
let (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json(&url, &ct, &body)
}
@@ -1,9 +1,43 @@
//! Usage permission and earned-reset payloads, including compatibility with older backends.
use super::*;
use crate::types::ConsumeRateLimitResetCreditCode;
use crate::types::RateLimitResetCreditDetails;
use crate::types::RateLimitResetCreditsDetails;
use crate::types::RateLimitResetCreditsSummary;
use pretty_assertions::assert_eq;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test]
async fn ordinary_usage_permission_comes_from_backend_not_display_percent() {
for (allowed, used_percent) in [(Some(true), 100), (Some(false), 0), (None, 0)] {
let server = MockServer::start().await;
let rate_limit = allowed.map(|allowed| {
serde_json::json!({
"allowed": allowed, "limit_reached": !allowed,
"primary_window": {"used_percent": used_percent, "limit_window_seconds": 300,
"reset_after_seconds": 60, "reset_at": 2000000000}
})
});
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"plan_type": "plus", "rate_limit": rate_limit
})))
.expect(1)
.mount(&server)
.await;
let response = test_client(&server.uri(), PathStyle::CodexApi)
.get_rate_limits_with_reset_credits()
.await
.unwrap();
assert_eq!(response.ordinary_usage_allowed, allowed);
}
}
#[test]
fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
+2
View File
@@ -44,6 +44,8 @@ pub struct RateLimitResetCreditDetails {
#[derive(Clone, Debug, PartialEq)]
pub struct RateLimitsWithResetCredits {
pub rate_limits: Vec<RateLimitSnapshot>,
/// Backend decision for ordinary included usage; absence is not permission to recover.
pub ordinary_usage_allowed: Option<bool>,
pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>,
pub account_id: Option<String>,
pub user_id: Option<String>,
@@ -40,6 +40,7 @@ fn rate_limit_snapshot(
fn account_rate_limits_response(snapshot: RateLimitSnapshot) -> GetAccountRateLimitsResponse {
GetAccountRateLimitsResponse {
ordinary_usage_allowed: None,
account_id: None,
rate_limit_upsell: None,
rate_limits: snapshot,
+1
View File
@@ -2503,6 +2503,7 @@ mod tests {
#[test]
fn app_server_rate_limit_snapshots_deduplicates_top_level_limit_from_map() {
let response = GetAccountRateLimitsResponse {
ordinary_usage_allowed: None,
account_id: None,
rate_limit_upsell: None,
rate_limits: rate_limit_snapshot("codex"),
@@ -16,6 +16,7 @@ fn banner_response(
banner["presentation"] = json!(presentation);
}
GetAccountRateLimitsResponse {
ordinary_usage_allowed: None,
account_id: Some("workspace-a".into()),
rate_limit_upsell: Some(banner),
rate_limits: snapshot(/*percent*/ 25.0),