[app-server] Include reset-credit details in rate limits (#30395)

## Why

Supported clients currently receive only a reset-credit count from
`account/rateLimits/read`. The redemption UI and other app-server
clients need each available credit's expiry and ID so they can explain
what will expire and consume the credit a user selected. This
information belongs on the existing rate-limit read surface rather than
a second app-server list RPC that clients would need to coordinate.

## What changed

- extend `rateLimitResetCredits` on `account/rateLimits/read` with
nullable `credits` detail rows
- fetch usage and reset-credit details concurrently; if the detail
request fails, times out, or cannot be parsed, preserve the usage
response and return `credits: null`
- expose each credit's ID, reset type, status, grant time, expiry time,
title, and description
- add an optional nullable `creditId` to
`account/rateLimitResetCredit/consume`; omitting it preserves the
existing automatic-selection behavior
- forward a selected credit ID to the Codex backend and update the
app-server documentation and generated schemas

The TUI consumer is stacked in #30488.

## Validation

- `just test -p codex-app-server-protocol` (251 passed)
- `just test -p codex-backend-client` (16 passed)
- `just test -p codex-app-server rate_limit` (18 passed)

Part of #29618.
This commit is contained in:
jay
2026-07-06 12:54:06 -07:00
committed by GitHub
parent 7affe3e3e4
commit 58ec528315
24 changed files with 967 additions and 53 deletions
@@ -556,6 +556,13 @@
},
"ConsumeAccountRateLimitResetCreditParams": {
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -8688,6 +8688,13 @@
"ConsumeAccountRateLimitResetCreditParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -14533,11 +14540,78 @@
],
"type": "string"
},
"RateLimitResetCredit": {
"properties": {
"description": {
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
},
"expiresAt": {
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"grantedAt": {
"description": "Unix timestamp in seconds when the credit was granted.",
"format": "int64",
"type": "integer"
},
"id": {
"description": "Opaque backend identifier for this reset credit.",
"type": "string"
},
"resetType": {
"$ref": "#/definitions/v2/RateLimitResetType"
},
"status": {
"$ref": "#/definitions/v2/RateLimitResetCreditStatus"
},
"title": {
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
}
},
"required": [
"grantedAt",
"id",
"resetType",
"status"
],
"type": "object"
},
"RateLimitResetCreditStatus": {
"enum": [
"available",
"redeeming",
"redeemed",
"unknown"
],
"type": "string"
},
"RateLimitResetCreditsSummary": {
"properties": {
"availableCount": {
"format": "int64",
"type": "integer"
},
"credits": {
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
"items": {
"$ref": "#/definitions/v2/RateLimitResetCredit"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -14545,6 +14619,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -4928,6 +4928,13 @@
"ConsumeAccountRateLimitResetCreditParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -10937,11 +10944,78 @@
],
"type": "string"
},
"RateLimitResetCredit": {
"properties": {
"description": {
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
},
"expiresAt": {
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"grantedAt": {
"description": "Unix timestamp in seconds when the credit was granted.",
"format": "int64",
"type": "integer"
},
"id": {
"description": "Opaque backend identifier for this reset credit.",
"type": "string"
},
"resetType": {
"$ref": "#/definitions/RateLimitResetType"
},
"status": {
"$ref": "#/definitions/RateLimitResetCreditStatus"
},
"title": {
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
}
},
"required": [
"grantedAt",
"id",
"resetType",
"status"
],
"type": "object"
},
"RateLimitResetCreditStatus": {
"enum": [
"available",
"redeeming",
"redeemed",
"unknown"
],
"type": "string"
},
"RateLimitResetCreditsSummary": {
"properties": {
"availableCount": {
"format": "int64",
"type": "integer"
},
"credits": {
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
"items": {
"$ref": "#/definitions/RateLimitResetCredit"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -10949,6 +11023,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -1,6 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -49,11 +49,78 @@
],
"type": "string"
},
"RateLimitResetCredit": {
"properties": {
"description": {
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
},
"expiresAt": {
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"grantedAt": {
"description": "Unix timestamp in seconds when the credit was granted.",
"format": "int64",
"type": "integer"
},
"id": {
"description": "Opaque backend identifier for this reset credit.",
"type": "string"
},
"resetType": {
"$ref": "#/definitions/RateLimitResetType"
},
"status": {
"$ref": "#/definitions/RateLimitResetCreditStatus"
},
"title": {
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
}
},
"required": [
"grantedAt",
"id",
"resetType",
"status"
],
"type": "object"
},
"RateLimitResetCreditStatus": {
"enum": [
"available",
"redeeming",
"redeemed",
"unknown"
],
"type": "string"
},
"RateLimitResetCreditsSummary": {
"properties": {
"availableCount": {
"format": "int64",
"type": "integer"
},
"credits": {
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
"items": {
"$ref": "#/definitions/RateLimitResetCredit"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -61,6 +128,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -7,4 +7,9 @@ export type ConsumeAccountRateLimitResetCreditParams = {
* Identifies one logical reset attempt. A UUID is recommended; reuse the same value when
* retrying that attempt.
*/
idempotencyKey: string, };
idempotencyKey: string,
/**
* Opaque reset-credit identifier to redeem. When omitted, the backend selects the next
* available credit.
*/
creditId?: string | null, };
@@ -0,0 +1,27 @@
// 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.
import type { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus";
import type { RateLimitResetType } from "./RateLimitResetType";
export type RateLimitResetCredit = {
/**
* Opaque backend identifier for this reset credit.
*/
id: string, resetType: RateLimitResetType, status: RateLimitResetCreditStatus,
/**
* Unix timestamp in seconds when the credit was granted.
*/
grantedAt: number,
/**
* Unix timestamp in seconds when the credit expires, or `null` if it does not expire.
*/
expiresAt: number | null,
/**
* Backend-provided display title for this credit, or `null` when unavailable.
*/
title: string | null,
/**
* Backend-provided display description for this credit, or `null` when unavailable.
*/
description: string | null, };
@@ -0,0 +1,5 @@
// 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.
export type RateLimitResetCreditStatus = "available" | "redeeming" | "redeemed" | "unknown";
@@ -1,5 +1,14 @@
// 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.
import type { RateLimitResetCredit } from "./RateLimitResetCredit";
export type RateLimitResetCreditsSummary = { availableCount: bigint, };
export type RateLimitResetCreditsSummary = { availableCount: bigint,
/**
* Detail rows for available reset credits, when the backend provides them.
*
* `null` means only `availableCount` is known, while an empty array means details were fetched
* and no available credits were returned. The backend may cap this list, so its length can be
* less than `availableCount`.
*/
credits: Array<RateLimitResetCredit> | null, };
@@ -0,0 +1,5 @@
// 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.
export type RateLimitResetType = "codexRateLimits" | "unknown";
@@ -335,7 +335,10 @@ export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotific
export type { ProcessOutputStream } from "./ProcessOutputStream";
export type { ProcessTerminalSize } from "./ProcessTerminalSize";
export type { RateLimitReachedType } from "./RateLimitReachedType";
export type { RateLimitResetCredit } from "./RateLimitResetCredit";
export type { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus";
export type { RateLimitResetCreditsSummary } from "./RateLimitResetCreditsSummary";
export type { RateLimitResetType } from "./RateLimitResetType";
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
export type { RateLimitWindow } from "./RateLimitWindow";
export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification";
@@ -284,6 +284,52 @@ pub struct GetAccountRateLimitsResponse {
#[ts(export_to = "v2/")]
pub struct RateLimitResetCreditsSummary {
pub available_count: i64,
/// Detail rows for available reset credits, when the backend provides them.
///
/// `null` means only `availableCount` is known, while an empty array means details were fetched
/// and no available credits were returned. The backend may cap this list, so its length can be
/// less than `availableCount`.
pub credits: Option<Vec<RateLimitResetCredit>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RateLimitResetCredit {
/// Opaque backend identifier for this reset credit.
pub id: String,
pub reset_type: RateLimitResetType,
pub status: RateLimitResetCreditStatus,
/// Unix timestamp in seconds when the credit was granted.
#[ts(type = "number")]
pub granted_at: i64,
/// Unix timestamp in seconds when the credit expires, or `null` if it does not expire.
#[ts(type = "number | null")]
pub expires_at: Option<i64>,
/// Backend-provided display title for this credit, or `null` when unavailable.
pub title: Option<String>,
/// Backend-provided display description for this credit, or `null` when unavailable.
pub description: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/", rename_all = "camelCase")]
pub enum RateLimitResetType {
CodexRateLimits,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/", rename_all = "camelCase")]
pub enum RateLimitResetCreditStatus {
Available,
Redeeming,
Redeemed,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -293,6 +339,10 @@ pub struct ConsumeAccountRateLimitResetCreditParams {
/// Identifies one logical reset attempt. A UUID is recommended; reuse the same value when
/// retrying that attempt.
pub idempotency_key: String,
/// Opaque reset-credit identifier to redeem. When omitted, the backend selects the next
/// available credit.
#[ts(optional = nullable)]
pub credit_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
+33 -6
View File
@@ -1929,8 +1929,8 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`.
- `account/logout` — sign out; triggers `account/updated`.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the number of earned rate-limit resets currently available. Rate-limit updates arrive via `account/rateLimits/updated` (notify); the reset count is snapshot-only.
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key.
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit data is snapshot-only.
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key, optionally selecting a reset-credit ID returned by `account/rateLimits/read`.
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available.
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
@@ -2032,7 +2032,30 @@ Field notes:
```json
{ "method": "account/rateLimits/read", "id": 7 }
{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null }, "rateLimitResetCredits": { "availableCount": 2 } } }
{
"id": 7,
"result": {
"rateLimits": {
"primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 },
"secondary": null,
"rateLimitReachedType": null
},
"rateLimitResetCredits": {
"availableCount": 2,
"credits": [
{
"id": "RateLimitResetCredit_1",
"resetType": "codexRateLimits",
"status": "available",
"grantedAt": 1781654400,
"expiresAt": 1784246400,
"title": "Full reset (Weekly + 5 hr)",
"description": "Ready to redeem"
}
]
}
}
}
{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } }
```
@@ -2043,23 +2066,27 @@ Field notes:
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
- `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.
- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`. Refetch `account/rateLimits/read` after consuming a reset.
- `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.
- The backend may cap `rateLimitResetCredits.credits`, so `availableCount` is the authoritative total and can be greater than the number of detail rows.
- Refetch `account/rateLimits/read` after consuming a reset.
### 8) Earned rate-limit resets (ChatGPT)
```json
{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868" } }
{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868", "creditId": "RateLimitResetCredit_1" } }
{ "id": 8, "result": { "outcome": "reset" } }
```
Field notes:
- `idempotencyKey` must be non-empty. A UUID is recommended for each logical redemption attempt; reuse the same value when retrying that attempt.
- `creditId` is optional. When provided, it must be a non-empty opaque ID returned by `account/rateLimits/read`; when omitted, the backend selects the next available credit.
- `reset` means a credit was consumed.
- `alreadyRedeemed` means the same redemption completed previously. Treat it as an idempotent success and refresh account limits.
- `nothingToReset` means there is no eligible rate-limit window to reset.
- `noCredit` means the account has no earned reset credits available.
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated windows from this response.
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated state from this response.
### 9) Workspace messages (ChatGPT)
@@ -156,7 +156,10 @@ use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RateLimitResetCredit;
use codex_app_server_protocol::RateLimitResetCreditStatus;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_app_server_protocol::RateLimitResetType;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
@@ -298,6 +301,8 @@ use codex_backend_client::CodexWorkspaceMessage as BackendWorkspaceMessage;
use codex_backend_client::CodexWorkspaceMessageType as BackendWorkspaceMessageType;
use codex_backend_client::CodexWorkspaceMessagesResponse as BackendWorkspaceMessagesResponse;
use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode;
use codex_backend_client::RateLimitResetCreditDetails as BackendRateLimitResetCreditDetails;
use codex_backend_client::RateLimitResetCreditsDetails as BackendRateLimitResetCreditsDetails;
use codex_backend_client::RequestError as BackendRequestError;
use codex_backend_client::TokenUsageProfile;
use codex_chatgpt::connectors;
@@ -891,9 +891,11 @@ impl AccountRequestProcessor {
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
let response = client
.get_rate_limits_with_reset_credits()
.await
let (response, detailed_rate_limit_reset_credits) = tokio::join!(
client.get_rate_limits_with_reset_credits(),
Self::detailed_rate_limit_reset_credits(&client),
);
let response = response
.map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;
if response.rate_limits.is_empty() {
return Err(internal_error(
@@ -920,6 +922,15 @@ impl AccountRequestProcessor {
.cloned()
.unwrap_or_else(|| response.rate_limits[0].clone());
let rate_limit_reset_credits = detailed_rate_limit_reset_credits.or_else(|| {
response
.rate_limit_reset_credits
.map(|summary| RateLimitResetCreditsSummary {
available_count: summary.available_count,
credits: None,
})
});
Ok(GetAccountRateLimitsResponse {
rate_limits: rate_limits.into(),
rate_limits_by_limit_id: Some(
@@ -928,11 +939,7 @@ impl AccountRequestProcessor {
.map(|(limit_id, snapshot)| (limit_id, snapshot.into()))
.collect(),
),
rate_limit_reset_credits: response.rate_limit_reset_credits.map(|summary| {
RateLimitResetCreditsSummary {
available_count: summary.available_count,
}
}),
rate_limit_reset_credits,
})
}
@@ -1,11 +1,47 @@
use super::*;
const RATE_LIMIT_RESET_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
const RATE_LIMIT_RESET_DETAILS_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 5);
#[cfg(debug_assertions)]
const RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR: &str =
"CODEX_TEST_RATE_LIMIT_RESET_REQUEST_TIMEOUT_MS";
impl AccountRequestProcessor {
pub(super) async fn detailed_rate_limit_reset_credits(
client: &BackendClient,
) -> Option<RateLimitResetCreditsSummary> {
let details = match tokio::time::timeout(
RATE_LIMIT_RESET_DETAILS_REQUEST_TIMEOUT,
client.list_rate_limit_reset_credits(),
)
.await
{
Ok(Ok(details)) => details,
Ok(Err(err)) => {
tracing::warn!(
"failed to fetch rate limit reset credit details; falling back to the usage response: {err}"
);
return None;
}
Err(_) => {
tracing::warn!(
"rate limit reset credit detail request timed out; falling back to the usage response"
);
return None;
}
};
match rate_limit_reset_credits_from_backend(details) {
Ok(summary) => Some(summary),
Err(err) => {
tracing::warn!(
"failed to parse rate limit reset credit details; falling back to the usage response: {err}"
);
None
}
}
}
pub(crate) async fn consume_account_rate_limit_reset_credit(
&self,
params: ConsumeAccountRateLimitResetCreditParams,
@@ -13,6 +49,9 @@ impl AccountRequestProcessor {
if params.idempotency_key.is_empty() {
return Err(invalid_request("idempotencyKey must not be empty"));
}
if params.credit_id.as_deref().is_some_and(str::is_empty) {
return Err(invalid_request("creditId must not be empty"));
}
let client = self.rate_limit_reset_backend_client().await?;
let request_timeout = RATE_LIMIT_RESET_REQUEST_TIMEOUT;
@@ -22,10 +61,20 @@ impl AccountRequestProcessor {
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or(request_timeout);
let response = tokio::time::timeout(
request_timeout,
client.consume_rate_limit_reset_credit(&params.idempotency_key),
)
let response = tokio::time::timeout(request_timeout, async {
match params.credit_id.as_deref() {
Some(credit_id) => {
client
.consume_rate_limit_reset_credit_by_id(&params.idempotency_key, credit_id)
.await
}
None => {
client
.consume_rate_limit_reset_credit(&params.idempotency_key)
.await
}
}
})
.await
.map_err(|_| internal_error("rate limit reset consume timed out"))?
.map_err(|err| internal_error(format!("failed to consume rate limit reset: {err}")))?;
@@ -64,3 +113,56 @@ impl AccountRequestProcessor {
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))
}
}
fn rate_limit_reset_credits_from_backend(
details: BackendRateLimitResetCreditsDetails,
) -> Result<RateLimitResetCreditsSummary, String> {
let credits = details
.credits
.into_iter()
.map(rate_limit_reset_credit_from_backend)
.collect::<Result<Vec<_>, _>>()?;
Ok(RateLimitResetCreditsSummary {
available_count: details.available_count,
credits: Some(credits),
})
}
fn rate_limit_reset_credit_from_backend(
credit: BackendRateLimitResetCreditDetails,
) -> Result<RateLimitResetCredit, String> {
let reset_type = match credit.reset_type.as_str() {
"codex_rate_limits" => RateLimitResetType::CodexRateLimits,
_ => RateLimitResetType::Unknown,
};
let status = match credit.status.as_str() {
"available" => RateLimitResetCreditStatus::Available,
"redeeming" => RateLimitResetCreditStatus::Redeeming,
"redeemed" => RateLimitResetCreditStatus::Redeemed,
_ => RateLimitResetCreditStatus::Unknown,
};
let granted_at = rate_limit_reset_credit_timestamp(&credit.granted_at)
.map_err(|err| format!("invalid granted_at for credit `{}`: {err}", credit.id))?;
let expires_at = credit
.expires_at
.as_deref()
.map(rate_limit_reset_credit_timestamp)
.transpose()
.map_err(|err| format!("invalid expires_at for credit `{}`: {err}", credit.id))?;
Ok(RateLimitResetCredit {
id: credit.id,
reset_type,
status,
granted_at,
expires_at,
title: credit.title,
description: credit.description,
})
}
fn rate_limit_reset_credit_timestamp(timestamp: &str) -> Result<i64, String> {
DateTime::parse_from_rfc3339(timestamp)
.map(|timestamp| timestamp.timestamp())
.map_err(|err| format!("failed to parse timestamp `{timestamp}`: {err}"))
}
@@ -44,6 +44,7 @@ async fn consume_rate_limit_reset_credit_requires_chatgpt_auth() -> Result<()> {
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: "request-1".to_string(),
credit_id: None,
},
)
.await?;
@@ -120,6 +121,44 @@ async fn consume_account_rate_limit_reset_credit_maps_backend_outcomes() -> Resu
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_forwards_selected_credit_id() -> Result<()> {
let (codex_home, server) = chatgpt_test_context().await?;
Mock::given(method("POST"))
.and(path("/api/codex/rate-limit-reset-credits/consume"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.and(body_json(json!({
"redeem_request_id": "request-selected",
"credit_id": "credit-123",
})))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({ "code": "reset", "windows_reset": 2 })),
)
.expect(1)
.mount(&server)
.await;
let mut mcp = initialized_app_server(codex_home.path()).await?;
let request_id = mcp
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: "request-selected".to_string(),
credit_id: Some("credit-123".to_string()),
},
)
.await?;
assert_eq!(
read_response::<ConsumeAccountRateLimitResetCreditResponse>(&mut mcp, request_id).await?,
ConsumeAccountRateLimitResetCreditResponse {
outcome: ConsumeAccountRateLimitResetCreditOutcome::Reset,
}
);
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key() -> Result<()> {
let (codex_home, _server) = chatgpt_test_context().await?;
@@ -129,6 +168,7 @@ async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key()
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: String::new(),
credit_id: None,
},
)
.await?;
@@ -139,6 +179,26 @@ async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key()
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_rejects_empty_credit_id() -> Result<()> {
let (codex_home, _server) = chatgpt_test_context().await?;
let mut mcp = initialized_app_server(codex_home.path()).await?;
let request_id = mcp
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: "request-1".to_string(),
credit_id: Some(String::new()),
},
)
.await?;
let error = read_error_response(&mut mcp, request_id).await?;
assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(error.error.message, "creditId must not be empty");
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_surfaces_backend_failure() -> Result<()> {
let (codex_home, server) = chatgpt_test_context().await?;
@@ -244,6 +304,7 @@ async fn send_consume_reset_credit(mcp: &mut TestAppServer, idempotency_key: &st
mcp.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: idempotency_key.to_string(),
credit_id: None,
},
)
.await
@@ -10,7 +10,10 @@ use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::RateLimitReachedType;
use codex_app_server_protocol::RateLimitResetCredit;
use codex_app_server_protocol::RateLimitResetCreditStatus;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_app_server_protocol::RateLimitResetType;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::RequestId;
@@ -109,6 +112,16 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
let secondary_reset_timestamp = chrono::DateTime::parse_from_rfc3339("2025-01-01T01:00:00Z")
.expect("parse secondary reset timestamp")
.timestamp();
let reset_credit_granted_at = chrono::DateTime::parse_from_rfc3339("2026-06-17T00:00:00Z")
.expect("parse reset credit grant timestamp")
.timestamp();
let reset_credit_expires_at = chrono::DateTime::parse_from_rfc3339("2026-07-17T00:00:00Z")
.expect("parse reset credit expiry timestamp")
.timestamp();
let second_reset_credit_granted_at =
chrono::DateTime::parse_from_rfc3339("2026-06-18T00:00:00Z")
.expect("parse second reset credit grant timestamp")
.timestamp();
let response_body = json!({
"plan_type": "pro",
"rate_limit": {
@@ -170,6 +183,35 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/codex/rate-limit-reset-credits"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"credits": [
{
"id": "credit-1",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-06-17T00:00:00Z",
"expires_at": "2026-07-17T00:00:00Z",
"title": "Full reset (Weekly + 5 hr)",
"description": "Ready to redeem"
},
{
"id": "credit-2",
"reset_type": "future_reset_type",
"status": "future_status",
"granted_at": "2026-06-18T00:00:00Z",
"expires_at": null
}
],
"available_count": 2,
"total_earned_count": 4
})))
.expect(1)
.mount(&server)
.await;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
@@ -260,13 +302,98 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
.into_iter()
.collect(),
),
rate_limit_reset_credits: Some(RateLimitResetCreditsSummary { available_count: 3 }),
rate_limit_reset_credits: Some(RateLimitResetCreditsSummary {
available_count: 2,
credits: Some(vec![
RateLimitResetCredit {
id: "credit-1".to_string(),
reset_type: RateLimitResetType::CodexRateLimits,
status: RateLimitResetCreditStatus::Available,
granted_at: reset_credit_granted_at,
expires_at: Some(reset_credit_expires_at),
title: Some("Full reset (Weekly + 5 hr)".to_string()),
description: Some("Ready to redeem".to_string()),
},
RateLimitResetCredit {
id: "credit-2".to_string(),
reset_type: RateLimitResetType::Unknown,
status: RateLimitResetCreditStatus::Unknown,
granted_at: second_reset_credit_granted_at,
expires_at: None,
title: None,
description: None,
},
]),
}),
};
assert_eq!(received, expected);
Ok(())
}
#[tokio::test]
async fn get_account_rate_limits_preserves_count_when_reset_credit_details_fail() -> Result<()> {
let codex_home = TempDir::new()?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
let server = MockServer::start().await;
write_chatgpt_base_url(codex_home.path(), &server.uri())?;
Mock::given(method("GET"))
.and(path("/api/codex/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plan_type": "pro",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 42,
"limit_window_seconds": 3600,
"reset_after_seconds": 120,
"reset_at": 1735689720
}
},
"rate_limit_reset_credits": { "available_count": 3 }
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/codex/rate-limit-reset-credits"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.expect(1)
.mount(&server)
.await;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_get_account_rate_limits_request().await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountRateLimitsResponse = to_response(response)?;
assert_eq!(
received.rate_limit_reset_credits,
Some(RateLimitResetCreditsSummary {
available_count: 3,
credits: None,
})
);
Ok(())
}
#[tokio::test]
async fn send_add_credits_nudge_email_requires_auth() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -3,6 +3,7 @@
use super::Client;
use super::PathStyle;
use crate::types::ConsumeRateLimitResetCreditResponse;
use crate::types::RateLimitResetCreditsDetails;
use crate::types::RateLimitStatusWithResetCredits;
use crate::types::RateLimitsWithResetCredits;
use anyhow::Result;
@@ -13,6 +14,8 @@ use serde::Serialize;
#[derive(Serialize)]
struct ConsumeRateLimitResetCreditRequest<'a> {
redeem_request_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
credit_id: Option<&'a str>,
}
impl Client {
@@ -31,9 +34,34 @@ impl Client {
self.decode_json(&url, &ct, &body)
}
pub async fn list_rate_limit_reset_credits(&self) -> Result<RateLimitResetCreditsDetails> {
let url = self.rate_limit_reset_credits_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> {
self.consume_rate_limit_reset_credit_request(redeem_request_id, /*credit_id*/ None)
.await
}
pub async fn consume_rate_limit_reset_credit_by_id(
&self,
redeem_request_id: &str,
credit_id: &str,
) -> Result<ConsumeRateLimitResetCreditResponse> {
self.consume_rate_limit_reset_credit_request(redeem_request_id, Some(credit_id))
.await
}
async fn consume_rate_limit_reset_credit_request(
&self,
redeem_request_id: &str,
credit_id: Option<&str>,
) -> Result<ConsumeRateLimitResetCreditResponse> {
let url = self.consume_rate_limit_reset_credit_url();
let req = self
@@ -41,7 +69,10 @@ impl Client {
.post(&url)
.headers(self.headers())
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&ConsumeRateLimitResetCreditRequest { redeem_request_id });
.json(&ConsumeRateLimitResetCreditRequest {
redeem_request_id,
credit_id,
});
let (body, ct) = self.exec_request(req, "POST", &url).await?;
self.decode_json(&url, &ct, &body)
}
@@ -53,6 +84,17 @@ impl Client {
}
}
fn rate_limit_reset_credits_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => {
format!("{}/api/codex/rate-limit-reset-credits", self.base_url)
}
PathStyle::ChatGptApi => {
format!("{}/wham/rate-limit-reset-credits", self.base_url)
}
}
}
fn consume_rate_limit_reset_credit_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => {
@@ -1,5 +1,7 @@
use super::*;
use crate::types::ConsumeRateLimitResetCreditCode;
use crate::types::RateLimitResetCreditDetails;
use crate::types::RateLimitResetCreditsDetails;
use crate::types::RateLimitResetCreditsSummary;
use pretty_assertions::assert_eq;
@@ -9,6 +11,10 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
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).rate_limit_reset_credits_url(),
"https://example.test/api/codex/rate-limit-reset-credits"
);
assert_eq!(
test_client("https://example.test", PathStyle::CodexApi)
.consume_rate_limit_reset_credit_url(),
@@ -19,6 +25,11 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
.rate_limit_status_url(),
"https://chatgpt.com/backend-api/wham/usage"
);
assert_eq!(
test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi)
.rate_limit_reset_credits_url(),
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"
);
assert_eq!(
test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi)
.consume_rate_limit_reset_credit_url(),
@@ -28,10 +39,22 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
assert_eq!(
serde_json::to_value(ConsumeRateLimitResetCreditRequest {
redeem_request_id: "redeem-123",
credit_id: None,
})
.unwrap(),
serde_json::json!({ "redeem_request_id": "redeem-123" })
);
assert_eq!(
serde_json::to_value(ConsumeRateLimitResetCreditRequest {
redeem_request_id: "redeem-456",
credit_id: Some("credit-123"),
})
.unwrap(),
serde_json::json!({
"redeem_request_id": "redeem-456",
"credit_id": "credit-123",
})
);
let status: RateLimitStatusWithResetCredits = serde_json::from_value(serde_json::json!({
"plan_type": "plus",
@@ -43,6 +66,60 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
Some(RateLimitResetCreditsSummary { available_count: 3 })
);
let details: RateLimitResetCreditsDetails = serde_json::from_value(serde_json::json!({
"credits": [
{
"id": "credit-1",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-06-17T00:00:00Z",
"expires_at": "2026-07-17T00:00:00Z",
"redeem_started_at": null,
"redeemed_at": null,
"profile_image_url": "https://example.test/avatar.png",
"profile_user_id": "@friend",
"title": "Full reset (Weekly + 5 hr)",
"description": "Ready to redeem"
},
{
"id": "credit-2",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-06-18T00:00:00Z",
"expires_at": null
}
],
"available_count": 2,
"total_earned_count": 4
}))
.unwrap();
assert_eq!(
details,
RateLimitResetCreditsDetails {
credits: vec![
RateLimitResetCreditDetails {
id: "credit-1".to_string(),
reset_type: "codex_rate_limits".to_string(),
status: "available".to_string(),
granted_at: "2026-06-17T00:00:00Z".to_string(),
expires_at: Some("2026-07-17T00:00:00Z".to_string()),
title: Some("Full reset (Weekly + 5 hr)".to_string()),
description: Some("Ready to redeem".to_string()),
},
RateLimitResetCreditDetails {
id: "credit-2".to_string(),
reset_type: "codex_rate_limits".to_string(),
status: "available".to_string(),
granted_at: "2026-06-18T00:00:00Z".to_string(),
expires_at: None,
title: None,
description: None,
},
],
available_count: 2,
}
);
let response: ConsumeRateLimitResetCreditResponse = serde_json::from_value(serde_json::json!({
"code": "reset",
"credit": { "id": "ignored-by-cli" },
+2
View File
@@ -18,6 +18,8 @@ pub use types::DeliveredConfigToml;
pub use types::DeliveredRequirementsToml;
pub use types::DeliveredTomlFragment;
pub use types::PaginatedListTaskListItem;
pub use types::RateLimitResetCreditDetails;
pub use types::RateLimitResetCreditsDetails;
pub use types::RateLimitResetCreditsSummary;
pub use types::RateLimitsWithResetCredits;
pub use types::TaskListItem;
+17
View File
@@ -23,6 +23,23 @@ pub struct RateLimitResetCreditsSummary {
pub available_count: i64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct RateLimitResetCreditsDetails {
pub credits: Vec<RateLimitResetCreditDetails>,
pub available_count: i64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct RateLimitResetCreditDetails {
pub id: String,
pub reset_type: String,
pub status: String,
pub granted_at: String,
pub expires_at: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RateLimitsWithResetCredits {
pub rate_limits: Vec<RateLimitSnapshot>,
+4 -1
View File
@@ -798,7 +798,10 @@ pub(super) async fn consume_rate_limit_reset_credit_request(
request_handle
.request_typed(ClientRequest::ConsumeAccountRateLimitResetCredit {
request_id,
params: ConsumeAccountRateLimitResetCreditParams { idempotency_key },
params: ConsumeAccountRateLimitResetCreditParams {
idempotency_key,
credit_id: None,
},
})
.await
.wrap_err("account/rateLimitResetCredit/consume failed in TUI")
+120 -30
View File
@@ -14,7 +14,10 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() {
assert!(chat.finish_rate_limit_reset_hint_refresh(
request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
Ok(RateLimitResetCreditsSummary {
available_count: 2,
credits: None
}),
));
chat.dispatch_command(SlashCommand::Usage);
@@ -35,7 +38,10 @@ async fn usage_command_disables_reset_after_cached_zero_snapshot() {
assert!(chat.finish_rate_limit_reset_hint_refresh(
request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
Ok(RateLimitResetCreditsSummary {
available_count: 0,
credits: None
}),
));
chat.dispatch_command(SlashCommand::Usage);
@@ -63,7 +69,10 @@ async fn usage_menu_refresh_enables_newly_available_reset() {
assert!(chat.finish_rate_limit_reset_hint_refresh(
request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
Ok(RateLimitResetCreditsSummary {
available_count: 0,
credits: None
}),
));
chat.dispatch_command(SlashCommand::Usage);
@@ -76,7 +85,10 @@ async fn usage_menu_refresh_enables_newly_available_reset() {
chat.finish_usage_menu_rate_limit_refresh(
/*request_id*/ 1,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
Ok(RateLimitResetCreditsSummary {
available_count: 1,
credits: None,
}),
);
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
@@ -92,7 +104,10 @@ async fn usage_menu_refresh_failure_preserves_disabled_known_zero() {
assert!(chat.finish_rate_limit_reset_hint_refresh(
request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
Ok(RateLimitResetCreditsSummary {
available_count: 0,
credits: None
}),
));
chat.dispatch_command(SlashCommand::Usage);
@@ -122,7 +137,10 @@ async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unc
assert!(chat.finish_rate_limit_reset_hint_refresh(
startup_request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
Ok(RateLimitResetCreditsSummary {
available_count: 0,
credits: None
}),
));
chat.dispatch_command(SlashCommand::Usage);
assert_matches!(
@@ -139,7 +157,10 @@ async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unc
chat.finish_usage_menu_rate_limit_refresh(
/*request_id*/ 1,
vec![snapshot(/*percent*/ 92.0)],
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
Ok(RateLimitResetCreditsSummary {
available_count: 2,
credits: None,
}),
);
assert_eq!(chat.available_rate_limit_reset_credits, None);
@@ -185,7 +206,10 @@ async fn usage_menu_rate_limit_reset_entry_opens_reset_flow() {
assert!(chat.finish_rate_limit_reset_hint_refresh(
request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
Ok(RateLimitResetCreditsSummary {
available_count: 2,
credits: None
}),
));
chat.dispatch_command(SlashCommand::Usage);
@@ -206,7 +230,10 @@ async fn rate_limit_reset_popup_states_snapshot() {
assert!(chat.finish_rate_limit_reset_credits_refresh(
loading_request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
Ok(RateLimitResetCreditsSummary {
available_count: 2,
credits: None
}),
));
record_popup(&chat, &mut states);
@@ -215,7 +242,10 @@ async fn rate_limit_reset_popup_states_snapshot() {
assert!(chat.finish_rate_limit_reset_credits_refresh(
empty_request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
Ok(RateLimitResetCreditsSummary {
available_count: 0,
credits: None
}),
));
record_popup(&chat, &mut states);
@@ -270,7 +300,10 @@ async fn rate_limit_reset_popup_states_snapshot() {
assert!(chat.finish_post_consume_reset_credits_refresh(
success_request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
Ok(RateLimitResetCreditsSummary {
available_count: 1,
credits: None
}),
));
record_popup(&chat, &mut states);
@@ -288,7 +321,10 @@ async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_sna
assert!(chat.finish_rate_limit_reset_credits_refresh(
free_request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
Ok(RateLimitResetCreditsSummary {
available_count: 1,
credits: None
}),
));
states.push(format!(
"Free:\n{}",
@@ -301,7 +337,10 @@ async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_sna
assert!(chat.finish_rate_limit_reset_credits_refresh(
go_request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
Ok(RateLimitResetCreditsSummary {
available_count: 1,
credits: None
}),
));
states.push(format!("Go:\n{}", render_bottom_popup(&chat, /*width*/ 80)));
@@ -317,7 +356,10 @@ async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_sna
assert!(chat.finish_rate_limit_reset_credits_refresh(
business_request_id,
vec![monthly_business_snapshot],
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
Ok(RateLimitResetCreditsSummary {
available_count: 1,
credits: None
}),
));
states.push(format!(
"Business with monthly window:\n{}",
@@ -337,7 +379,10 @@ async fn rate_limit_reset_confirmation_selects_cancel_by_default() {
assert!(chat.finish_rate_limit_reset_credits_refresh(
request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
Ok(RateLimitResetCreditsSummary {
available_count: 1,
credits: None
}),
));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
@@ -353,7 +398,10 @@ async fn rate_limit_reset_confirmation_can_use_reset() {
assert!(chat.finish_rate_limit_reset_credits_refresh(
request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
Ok(RateLimitResetCreditsSummary {
available_count: 1,
credits: None
}),
));
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
@@ -393,7 +441,10 @@ async fn no_credit_outcome_disables_reset_entry_in_usage_menu() {
assert!(chat.finish_rate_limit_reset_hint_refresh(
startup_request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
Ok(RateLimitResetCreditsSummary {
available_count: 1,
credits: None
}),
));
let consume_request_id = chat.show_rate_limit_reset_consuming_popup();
assert!(!finish_reset_consume_outcome(
@@ -438,7 +489,10 @@ async fn rate_limit_reset_redemption_cannot_be_dismissed_while_in_flight() {
assert!(chat.finish_post_consume_reset_credits_refresh(
request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
Ok(RateLimitResetCreditsSummary {
available_count: 1,
credits: None
}),
));
dismiss_popup(&mut chat);
assert!(chat.bottom_pane.no_modal_or_popup_active());
@@ -469,7 +523,10 @@ async fn already_redeemed_is_an_idempotent_success() {
assert!(chat.finish_post_consume_reset_credits_refresh(
request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
Ok(RateLimitResetCreditsSummary {
available_count: 0,
credits: None
}),
));
assert!(
render_bottom_popup(&chat, /*width*/ 80)
@@ -485,7 +542,10 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() {
assert!(chat.finish_rate_limit_reset_hint_refresh(
startup_request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
Ok(RateLimitResetCreditsSummary {
available_count: 2,
credits: None
}),
));
let consume_request_id = chat.show_rate_limit_reset_consuming_popup();
assert!(finish_reset_consume_outcome(
@@ -522,7 +582,10 @@ async fn account_change_invalidates_pending_reset_requests() {
assert!(!chat.finish_rate_limit_reset_credits_refresh(
request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
Ok(RateLimitResetCreditsSummary {
available_count: 2,
credits: None
}),
));
assert!(chat.bottom_pane.no_modal_or_popup_active());
}
@@ -536,7 +599,10 @@ async fn clearing_pending_reset_hint_preserves_in_flight_redemption() {
assert!(chat.finish_rate_limit_reset_hint_refresh(
hint_request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
Ok(RateLimitResetCreditsSummary {
available_count: 2,
credits: None
}),
));
chat.clear_pending_rate_limit_reset_hint();
@@ -559,7 +625,10 @@ async fn rate_limit_reset_load_result_updates_popup_beneath_overlay() {
assert!(chat.finish_rate_limit_reset_credits_refresh(
request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
Ok(RateLimitResetCreditsSummary {
available_count: 2,
credits: None
}),
));
assert_eq!(
chat.bottom_pane.active_view_id(),
@@ -588,7 +657,10 @@ async fn rate_limit_reset_success_updates_popup_beneath_overlay() {
assert!(chat.finish_post_consume_reset_credits_refresh(
request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 1 }),
Ok(RateLimitResetCreditsSummary {
available_count: 1,
credits: None
}),
));
assert_eq!(
chat.bottom_pane.active_view_id(),
@@ -631,7 +703,10 @@ async fn startup_check_shows_available_reset_hint_snapshot() {
assert!(chat.finish_rate_limit_reset_hint_refresh(
hint_request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
Ok(RateLimitResetCreditsSummary {
available_count: 2,
credits: None
}),
));
let rendered = lines_to_single_string(
&chat
@@ -654,7 +729,10 @@ async fn startup_reset_hint_waits_for_active_output_snapshot() {
assert!(chat.finish_rate_limit_reset_hint_refresh(
hint_request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
Ok(RateLimitResetCreditsSummary {
available_count: 2,
credits: None
}),
));
assert!(chat.usage_history_insertion_blocked());
@@ -685,7 +763,10 @@ async fn opening_rate_limit_reset_flow_invalidates_in_flight_startup_hint() {
assert!(!chat.finish_rate_limit_reset_hint_refresh(
hint_request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
Ok(RateLimitResetCreditsSummary {
available_count: 2,
credits: None
}),
));
assert!(chat.pending_rate_limit_reset_hint().is_none());
}
@@ -698,7 +779,10 @@ async fn starting_rate_limit_reset_redemption_clears_deferred_startup_hint() {
assert!(chat.finish_rate_limit_reset_hint_refresh(
hint_request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
Ok(RateLimitResetCreditsSummary {
available_count: 2,
credits: None
}),
));
assert!(chat.pending_rate_limit_reset_hint().is_some());
@@ -716,7 +800,10 @@ async fn startup_check_omits_reset_hint_when_none_are_available() {
assert!(chat.finish_rate_limit_reset_hint_refresh(
hint_request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
Ok(RateLimitResetCreditsSummary {
available_count: 0,
credits: None
}),
));
assert!(chat.pending_rate_limit_reset_hint().is_none());
}
@@ -731,7 +818,10 @@ async fn startup_check_shows_reset_hint_for_workspace_account_with_credit() {
assert!(chat.finish_rate_limit_reset_hint_refresh(
hint_request_id,
Vec::new(),
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
Ok(RateLimitResetCreditsSummary {
available_count: 2,
credits: None
}),
));
assert!(chat.pending_rate_limit_reset_hint().is_some());
assert_eq!(chat.available_rate_limit_reset_credits, Some(2));