mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
[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:
@@ -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"
|
||||
|
||||
+81
@@ -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": {
|
||||
|
||||
+81
@@ -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": {
|
||||
|
||||
+7
@@ -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"
|
||||
|
||||
+74
@@ -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": {
|
||||
|
||||
Generated
+6
-1
@@ -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, };
|
||||
+5
@@ -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";
|
||||
+10
-1
@@ -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)]
|
||||
|
||||
@@ -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(¶ms.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(¶ms.idempotency_key, credit_id)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
client
|
||||
.consume_rate_limit_reset_credit(¶ms.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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user