mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(app-server): expose rate-limit reset credits (#28143)
## Why Codex users can earn personal rate-limit reset credits, but app-server clients do not currently have an API for reading or redeeming them. This adds the backend and protocol foundation used by the `/usage` TUI flow in #28154. ## What changed - Extend `account/rateLimits/read` with a nullable `rateLimitResetCredits` summary sourced from the existing usage response. - Add backend-client and app-server support for consuming a reset with a caller-generated idempotency key. A UUID is recommended, and clients reuse the same key when retrying the same logical reset. - Return only the consume `outcome`; clients refetch `account/rateLimits/read` for updated window state. - Document the response field and each consume outcome, and regenerate the JSON and TypeScript schema fixtures. - Clarify in `AGENTS.md` that new app-server string enum values use camelCase on the wire. - Update the existing TUI response fixture for the expanded protocol shape. - Add coverage for authentication, response mapping, backend failures, consume outcomes, and request timeout behavior. ## Validation - `just test -p codex-app-server-protocol` — 231 passed. - `just test -p codex-backend-client` — 14 passed. - Focused `codex-app-server` reset-credit tests — 5 passed. - Focused `codex-tui` protocol response fixture test — passed. - `just fix -p codex-backend-client -p codex-app-server-protocol -p codex-app-server` — passed. - `just fmt` — passed.
This commit is contained in:
@@ -261,6 +261,7 @@ These guidelines apply to app-server protocol work in `codex-rs`, especially:
|
||||
`*Params` for request payloads, `*Response` for responses, and `*Notification` for notifications.
|
||||
- Expose RPC methods as `<resource>/<method>` and keep `<resource>` singular (for example, `thread/read`, `app/list`).
|
||||
- Always expose fields as camelCase on the wire with `#[serde(rename_all = "camelCase")]` unless a tagged union or explicit compatibility requirement needs a targeted rename.
|
||||
- Always expose string enum values as camelCase on the wire with matching serde and TS `rename_all = "camelCase"` annotations unless an explicit compatibility requirement needs targeted renames.
|
||||
- Exception: config RPC payloads are expected to use snake_case to mirror config.toml keys (see the config read/write/list APIs in `app-server-protocol/src/protocol/v2.rs`).
|
||||
- Always set `#[ts(export_to = "v2/")]` on v2 request/response/notification types so generated TypeScript lands in the correct namespace.
|
||||
- Never use `#[serde(skip_serializing_if = "Option::is_none")]` for v2 API payload fields.
|
||||
|
||||
@@ -554,6 +554,18 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ConsumeAccountRateLimitResetCreditParams": {
|
||||
"properties": {
|
||||
"idempotencyKey": {
|
||||
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"idempotencyKey"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ContentItem": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -6179,6 +6191,30 @@
|
||||
"title": "Account/rateLimits/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/rateLimitResetCredit/consume"
|
||||
],
|
||||
"title": "Account/rateLimitResetCredit/consumeRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ConsumeAccountRateLimitResetCreditParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Account/rateLimitResetCredit/consumeRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
+105
@@ -1857,6 +1857,30 @@
|
||||
"title": "Account/rateLimits/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/rateLimitResetCredit/consume"
|
||||
],
|
||||
"title": "Account/rateLimitResetCredit/consumeRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/ConsumeAccountRateLimitResetCreditParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Account/rateLimitResetCredit/consumeRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -8453,6 +8477,65 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ConsumeAccountRateLimitResetCreditOutcome": {
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "A reset credit was consumed and the eligible rate-limit windows were reset.",
|
||||
"enum": [
|
||||
"reset"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "No current rate-limit window is eligible for a reset.",
|
||||
"enum": [
|
||||
"nothingToReset"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The account has no earned reset credits available.",
|
||||
"enum": [
|
||||
"noCredit"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The same idempotency key already completed a reset successfully.",
|
||||
"enum": [
|
||||
"alreadyRedeemed"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ConsumeAccountRateLimitResetCreditParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"idempotencyKey": {
|
||||
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"idempotencyKey"
|
||||
],
|
||||
"title": "ConsumeAccountRateLimitResetCreditParams",
|
||||
"type": "object"
|
||||
},
|
||||
"ConsumeAccountRateLimitResetCreditResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"outcome": {
|
||||
"$ref": "#/definitions/v2/ConsumeAccountRateLimitResetCreditOutcome"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"outcome"
|
||||
],
|
||||
"title": "ConsumeAccountRateLimitResetCreditResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ContentItem": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -9990,6 +10073,16 @@
|
||||
"GetAccountRateLimitsResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"rateLimitResetCredits": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/RateLimitResetCreditsSummary"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rateLimits": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -13893,6 +13986,18 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitResetCreditsSummary": {
|
||||
"properties": {
|
||||
"availableCount": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"availableCount"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RateLimitSnapshot": {
|
||||
"properties": {
|
||||
"credits": {
|
||||
|
||||
+105
@@ -2841,6 +2841,30 @@
|
||||
"title": "Account/rateLimits/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/rateLimitResetCredit/consume"
|
||||
],
|
||||
"title": "Account/rateLimitResetCredit/consumeRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ConsumeAccountRateLimitResetCreditParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Account/rateLimitResetCredit/consumeRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -4766,6 +4790,65 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ConsumeAccountRateLimitResetCreditOutcome": {
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "A reset credit was consumed and the eligible rate-limit windows were reset.",
|
||||
"enum": [
|
||||
"reset"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "No current rate-limit window is eligible for a reset.",
|
||||
"enum": [
|
||||
"nothingToReset"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The account has no earned reset credits available.",
|
||||
"enum": [
|
||||
"noCredit"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The same idempotency key already completed a reset successfully.",
|
||||
"enum": [
|
||||
"alreadyRedeemed"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ConsumeAccountRateLimitResetCreditParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"idempotencyKey": {
|
||||
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"idempotencyKey"
|
||||
],
|
||||
"title": "ConsumeAccountRateLimitResetCreditParams",
|
||||
"type": "object"
|
||||
},
|
||||
"ConsumeAccountRateLimitResetCreditResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"outcome": {
|
||||
"$ref": "#/definitions/ConsumeAccountRateLimitResetCreditOutcome"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"outcome"
|
||||
],
|
||||
"title": "ConsumeAccountRateLimitResetCreditResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ContentItem": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -6414,6 +6497,16 @@
|
||||
"GetAccountRateLimitsResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"rateLimitResetCredits": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RateLimitResetCreditsSummary"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rateLimits": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -10366,6 +10459,18 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitResetCreditsSummary": {
|
||||
"properties": {
|
||||
"availableCount": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"availableCount"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RateLimitSnapshot": {
|
||||
"properties": {
|
||||
"credits": {
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"idempotencyKey": {
|
||||
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"idempotencyKey"
|
||||
],
|
||||
"title": "ConsumeAccountRateLimitResetCreditParams",
|
||||
"type": "object"
|
||||
}
|
||||
Generated
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"ConsumeAccountRateLimitResetCreditOutcome": {
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "A reset credit was consumed and the eligible rate-limit windows were reset.",
|
||||
"enum": [
|
||||
"reset"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "No current rate-limit window is eligible for a reset.",
|
||||
"enum": [
|
||||
"nothingToReset"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The account has no earned reset credits available.",
|
||||
"enum": [
|
||||
"noCredit"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The same idempotency key already completed a reset successfully.",
|
||||
"enum": [
|
||||
"alreadyRedeemed"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"outcome": {
|
||||
"$ref": "#/definitions/ConsumeAccountRateLimitResetCreditOutcome"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"outcome"
|
||||
],
|
||||
"title": "ConsumeAccountRateLimitResetCreditResponse",
|
||||
"type": "object"
|
||||
}
|
||||
+22
@@ -49,6 +49,18 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitResetCreditsSummary": {
|
||||
"properties": {
|
||||
"availableCount": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"availableCount"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RateLimitSnapshot": {
|
||||
"properties": {
|
||||
"credits": {
|
||||
@@ -179,6 +191,16 @@
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"rateLimitResetCredits": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RateLimitResetCreditsSummary"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rateLimits": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
File diff suppressed because one or more lines are too long
Generated
+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 ConsumeAccountRateLimitResetCreditOutcome = "reset" | "nothingToReset" | "noCredit" | "alreadyRedeemed";
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
// 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 ConsumeAccountRateLimitResetCreditParams = {
|
||||
/**
|
||||
* Identifies one logical reset attempt. A UUID is recommended; reuse the same value when
|
||||
* retrying that attempt.
|
||||
*/
|
||||
idempotencyKey: string, };
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
// 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 { ConsumeAccountRateLimitResetCreditOutcome } from "./ConsumeAccountRateLimitResetCreditOutcome";
|
||||
|
||||
export type ConsumeAccountRateLimitResetCreditResponse = { outcome: ConsumeAccountRateLimitResetCreditOutcome, };
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
// 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 { RateLimitResetCreditsSummary } from "./RateLimitResetCreditsSummary";
|
||||
import type { RateLimitSnapshot } from "./RateLimitSnapshot";
|
||||
|
||||
export type GetAccountRateLimitsResponse = {
|
||||
@@ -11,4 +12,4 @@ rateLimits: RateLimitSnapshot,
|
||||
/**
|
||||
* Multi-bucket view keyed by metered `limit_id` (for example, `codex`).
|
||||
*/
|
||||
rateLimitsByLimitId: { [key in string]?: RateLimitSnapshot } | null, };
|
||||
rateLimitsByLimitId: { [key in string]?: RateLimitSnapshot } | null, rateLimitResetCredits: RateLimitResetCreditsSummary | 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 RateLimitResetCreditsSummary = { availableCount: bigint, };
|
||||
@@ -85,6 +85,9 @@ export type { ConfigWarningNotification } from "./ConfigWarningNotification";
|
||||
export type { ConfigWriteResponse } from "./ConfigWriteResponse";
|
||||
export type { ConfiguredHookHandler } from "./ConfiguredHookHandler";
|
||||
export type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup";
|
||||
export type { ConsumeAccountRateLimitResetCreditOutcome } from "./ConsumeAccountRateLimitResetCreditOutcome";
|
||||
export type { ConsumeAccountRateLimitResetCreditParams } from "./ConsumeAccountRateLimitResetCreditParams";
|
||||
export type { ConsumeAccountRateLimitResetCreditResponse } from "./ConsumeAccountRateLimitResetCreditResponse";
|
||||
export type { ContextCompactedNotification } from "./ContextCompactedNotification";
|
||||
export type { CreditsSnapshot } from "./CreditsSnapshot";
|
||||
export type { DeprecationNoticeNotification } from "./DeprecationNoticeNotification";
|
||||
@@ -323,6 +326,7 @@ export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotific
|
||||
export type { ProcessOutputStream } from "./ProcessOutputStream";
|
||||
export type { ProcessTerminalSize } from "./ProcessTerminalSize";
|
||||
export type { RateLimitReachedType } from "./RateLimitReachedType";
|
||||
export type { RateLimitResetCreditsSummary } from "./RateLimitResetCreditsSummary";
|
||||
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
|
||||
export type { RateLimitWindow } from "./RateLimitWindow";
|
||||
export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification";
|
||||
|
||||
@@ -1004,6 +1004,12 @@ client_request_definitions! {
|
||||
response: v2::GetAccountRateLimitsResponse,
|
||||
},
|
||||
|
||||
ConsumeAccountRateLimitResetCredit => "account/rateLimitResetCredit/consume" {
|
||||
params: v2::ConsumeAccountRateLimitResetCreditParams,
|
||||
serialization: global("account-auth"),
|
||||
response: v2::ConsumeAccountRateLimitResetCreditResponse,
|
||||
},
|
||||
|
||||
GetAccountTokenUsage => "account/usage/read" {
|
||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||
serialization: None,
|
||||
|
||||
@@ -256,6 +256,44 @@ pub struct GetAccountRateLimitsResponse {
|
||||
pub rate_limits: RateLimitSnapshot,
|
||||
/// Multi-bucket view keyed by metered `limit_id` (for example, `codex`).
|
||||
pub rate_limits_by_limit_id: Option<HashMap<String, RateLimitSnapshot>>,
|
||||
pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct RateLimitResetCreditsSummary {
|
||||
pub available_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ConsumeAccountRateLimitResetCreditParams {
|
||||
/// Identifies one logical reset attempt. A UUID is recommended; reuse the same value when
|
||||
/// retrying that attempt.
|
||||
pub idempotency_key: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ConsumeAccountRateLimitResetCreditResponse {
|
||||
pub outcome: ConsumeAccountRateLimitResetCreditOutcome,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/", rename_all = "camelCase")]
|
||||
pub enum ConsumeAccountRateLimitResetCreditOutcome {
|
||||
/// A reset credit was consumed and the eligible rate-limit windows were reset.
|
||||
Reset,
|
||||
/// No current rate-limit window is eligible for a reset.
|
||||
NothingToReset,
|
||||
/// The account has no earned reset credits available.
|
||||
NoCredit,
|
||||
/// The same idempotency key already completed a reset successfully.
|
||||
AlreadyRedeemed,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
|
||||
@@ -1866,7 +1866,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 and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify).
|
||||
- `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/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
|
||||
- `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.
|
||||
- `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit.
|
||||
@@ -1962,7 +1963,7 @@ Field notes:
|
||||
|
||||
```json
|
||||
{ "method": "account/rateLimits/read", "id": 7 }
|
||||
{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null } } }
|
||||
{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null }, "rateLimitResetCredits": { "availableCount": 2 } } }
|
||||
{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } }
|
||||
```
|
||||
|
||||
@@ -1973,12 +1974,29 @@ 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.
|
||||
|
||||
### 8) Notify a workspace owner about a limit
|
||||
### 8) Earned rate-limit resets (ChatGPT)
|
||||
|
||||
```json
|
||||
{ "method": "account/sendAddCreditsNudgeEmail", "id": 8, "params": { "creditType": "credits" } }
|
||||
{ "id": 8, "result": { "status": "sent" } }
|
||||
{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868" } }
|
||||
{ "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.
|
||||
- `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.
|
||||
|
||||
### 9) Notify a workspace owner about a limit
|
||||
|
||||
```json
|
||||
{ "method": "account/sendAddCreditsNudgeEmail", "id": 9, "params": { "creditType": "credits" } }
|
||||
{ "id": 9, "result": { "status": "sent" } }
|
||||
```
|
||||
|
||||
Use `creditType: "credits"` when workspace credits are depleted, or `creditType: "usage_limit"` when the workspace usage limit has been reached. If the owner was already notified recently, the response status is `cooldown_active`.
|
||||
|
||||
@@ -1376,6 +1376,11 @@ impl MessageProcessor {
|
||||
ClientRequest::GetAccountRateLimits { .. } => {
|
||||
self.account_processor.get_account_rate_limits().await
|
||||
}
|
||||
ClientRequest::ConsumeAccountRateLimitResetCredit { params, .. } => {
|
||||
self.account_processor
|
||||
.consume_account_rate_limit_reset_credit(params)
|
||||
.await
|
||||
}
|
||||
ClientRequest::GetAccountTokenUsage { .. } => {
|
||||
self.account_processor.get_account_token_usage().await
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ use codex_app_server_protocol::CommandExecResizeParams;
|
||||
use codex_app_server_protocol::CommandExecTerminateParams;
|
||||
use codex_app_server_protocol::CommandExecWriteParams;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome;
|
||||
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditParams;
|
||||
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
|
||||
use codex_app_server_protocol::ConversationGitInfo;
|
||||
use codex_app_server_protocol::ConversationSummary;
|
||||
use codex_app_server_protocol::DynamicToolFunctionSpec;
|
||||
@@ -149,6 +152,7 @@ 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::RateLimitResetCreditsSummary;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
|
||||
use codex_app_server_protocol::ReviewStartParams;
|
||||
@@ -279,6 +283,7 @@ use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode;
|
||||
use codex_backend_client::TokenUsageProfile;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_chatgpt::workspace_settings;
|
||||
@@ -401,7 +406,6 @@ use codex_protocol::protocol::GitInfo as CoreGitInfo;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
|
||||
use codex_protocol::protocol::RealtimeVoicesList;
|
||||
use codex_protocol::protocol::ResumedHistory;
|
||||
use codex_protocol::protocol::ReviewDelivery as CoreReviewDelivery;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
mod rate_limit_resets;
|
||||
|
||||
// Duration before a browser ChatGPT login attempt is abandoned.
|
||||
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
|
||||
@@ -851,19 +853,64 @@ impl AccountRequestProcessor {
|
||||
async fn get_account_rate_limits_response(
|
||||
&self,
|
||||
) -> Result<GetAccountRateLimitsResponse, JSONRPCErrorError> {
|
||||
self.fetch_account_rate_limits()
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return Err(invalid_request(
|
||||
"codex account authentication required to read rate limits",
|
||||
));
|
||||
};
|
||||
|
||||
if !auth.uses_codex_backend() {
|
||||
return Err(invalid_request(
|
||||
"chatgpt authentication required to read rate limits",
|
||||
));
|
||||
}
|
||||
|
||||
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
|
||||
.map(
|
||||
|(rate_limits, rate_limits_by_limit_id)| GetAccountRateLimitsResponse {
|
||||
rate_limits: rate_limits.into(),
|
||||
rate_limits_by_limit_id: Some(
|
||||
rate_limits_by_limit_id
|
||||
.into_iter()
|
||||
.map(|(limit_id, snapshot)| (limit_id, snapshot.into()))
|
||||
.collect(),
|
||||
),
|
||||
},
|
||||
)
|
||||
.map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;
|
||||
if response.rate_limits.is_empty() {
|
||||
return Err(internal_error(
|
||||
"failed to fetch codex rate limits: no snapshots returned",
|
||||
));
|
||||
}
|
||||
|
||||
let rate_limits_by_limit_id: HashMap<_, _> = response
|
||||
.rate_limits
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|snapshot| {
|
||||
let limit_id = snapshot
|
||||
.limit_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "codex".to_string());
|
||||
(limit_id, snapshot)
|
||||
})
|
||||
.collect();
|
||||
let rate_limits = response
|
||||
.rate_limits
|
||||
.iter()
|
||||
.find(|snapshot| snapshot.limit_id.as_deref() == Some("codex"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| response.rate_limits[0].clone());
|
||||
|
||||
Ok(GetAccountRateLimitsResponse {
|
||||
rate_limits: rate_limits.into(),
|
||||
rate_limits_by_limit_id: Some(
|
||||
rate_limits_by_limit_id
|
||||
.into_iter()
|
||||
.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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_account_token_usage_response(
|
||||
@@ -963,61 +1010,6 @@ impl AccountRequestProcessor {
|
||||
AddCreditsNudgeCreditType::UsageLimit => BackendAddCreditsNudgeCreditType::UsageLimit,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_account_rate_limits(
|
||||
&self,
|
||||
) -> Result<
|
||||
(
|
||||
CoreRateLimitSnapshot,
|
||||
HashMap<String, CoreRateLimitSnapshot>,
|
||||
),
|
||||
JSONRPCErrorError,
|
||||
> {
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return Err(invalid_request(
|
||||
"codex account authentication required to read rate limits",
|
||||
));
|
||||
};
|
||||
|
||||
if !auth.uses_codex_backend() {
|
||||
return Err(invalid_request(
|
||||
"chatgpt authentication required to read rate limits",
|
||||
));
|
||||
}
|
||||
|
||||
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 snapshots = client
|
||||
.get_rate_limits_many()
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;
|
||||
if snapshots.is_empty() {
|
||||
return Err(internal_error(
|
||||
"failed to fetch codex rate limits: no snapshots returned",
|
||||
));
|
||||
}
|
||||
|
||||
let rate_limits_by_limit_id: HashMap<String, CoreRateLimitSnapshot> = snapshots
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|snapshot| {
|
||||
let limit_id = snapshot
|
||||
.limit_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "codex".to_string());
|
||||
(limit_id, snapshot)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let primary = snapshots
|
||||
.iter()
|
||||
.find(|snapshot| snapshot.limit_id.as_deref() == Some("codex"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| snapshots[0].clone());
|
||||
|
||||
Ok((primary, rate_limits_by_limit_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
use super::*;
|
||||
|
||||
const RATE_LIMIT_RESET_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
|
||||
#[cfg(debug_assertions)]
|
||||
const RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR: &str =
|
||||
"CODEX_TEST_RATE_LIMIT_RESET_REQUEST_TIMEOUT_MS";
|
||||
|
||||
impl AccountRequestProcessor {
|
||||
pub(crate) async fn consume_account_rate_limit_reset_credit(
|
||||
&self,
|
||||
params: ConsumeAccountRateLimitResetCreditParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
if params.idempotency_key.is_empty() {
|
||||
return Err(invalid_request("idempotencyKey must not be empty"));
|
||||
}
|
||||
|
||||
let client = self.rate_limit_reset_backend_client().await?;
|
||||
let request_timeout = RATE_LIMIT_RESET_REQUEST_TIMEOUT;
|
||||
#[cfg(debug_assertions)]
|
||||
let request_timeout = std::env::var(RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR)
|
||||
.ok()
|
||||
.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),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| internal_error("rate limit reset consume timed out"))?
|
||||
.map_err(|err| internal_error(format!("failed to consume rate limit reset: {err}")))?;
|
||||
let outcome = match response.code {
|
||||
BackendConsumeRateLimitResetCreditCode::Reset => {
|
||||
ConsumeAccountRateLimitResetCreditOutcome::Reset
|
||||
}
|
||||
BackendConsumeRateLimitResetCreditCode::NothingToReset => {
|
||||
ConsumeAccountRateLimitResetCreditOutcome::NothingToReset
|
||||
}
|
||||
BackendConsumeRateLimitResetCreditCode::NoCredit => {
|
||||
ConsumeAccountRateLimitResetCreditOutcome::NoCredit
|
||||
}
|
||||
BackendConsumeRateLimitResetCreditCode::AlreadyRedeemed => {
|
||||
ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed
|
||||
}
|
||||
};
|
||||
Ok(Some(
|
||||
ConsumeAccountRateLimitResetCreditResponse { outcome }.into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn rate_limit_reset_backend_client(&self) -> Result<BackendClient, JSONRPCErrorError> {
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return Err(invalid_request(
|
||||
"codex account authentication required for rate limit reset credits",
|
||||
));
|
||||
};
|
||||
if !auth.uses_codex_backend() {
|
||||
return Err(invalid_request(
|
||||
"chatgpt authentication required for rate limit reset credits",
|
||||
));
|
||||
}
|
||||
|
||||
BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
|
||||
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ use codex_app_server_protocol::CommandExecWriteParams;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
use codex_app_server_protocol::ConfigReadParams;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditParams;
|
||||
use codex_app_server_protocol::ExperimentalFeatureListParams;
|
||||
use codex_app_server_protocol::FeedbackUploadParams;
|
||||
use codex_app_server_protocol::FsCopyParams;
|
||||
@@ -382,6 +383,18 @@ impl TestAppServer {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send an `account/rateLimitResetCredit/consume` JSON-RPC request.
|
||||
pub async fn send_consume_account_rate_limit_reset_credit_request(
|
||||
&mut self,
|
||||
params: ConsumeAccountRateLimitResetCreditParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
self.send_request(
|
||||
"account/rateLimitResetCredit/consume",
|
||||
Some(serde_json::to_value(params)?),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send an `account/sendAddCreditsNudgeEmail` JSON-RPC request.
|
||||
pub async fn send_add_credits_nudge_email_request(
|
||||
&mut self,
|
||||
|
||||
@@ -41,6 +41,7 @@ mod plugin_read;
|
||||
mod plugin_share;
|
||||
mod plugin_uninstall;
|
||||
mod process_exec;
|
||||
mod rate_limit_reset_credits;
|
||||
mod rate_limits;
|
||||
mod realtime_conversation;
|
||||
mod remote_control;
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome;
|
||||
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditParams;
|
||||
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
|
||||
use codex_app_server_protocol::GetAccountParams;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::LoginAccountResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::body_json;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 10);
|
||||
const RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR: &str =
|
||||
"CODEX_TEST_RATE_LIMIT_RESET_REQUEST_TIMEOUT_MS";
|
||||
const SERVER_TIMEOUT_READ_TIMEOUT: std::time::Duration =
|
||||
std::time::Duration::from_secs(/*secs*/ 15);
|
||||
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
|
||||
const INTERNAL_ERROR_CODE: i64 = -32603;
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_rate_limit_reset_credit_requires_chatgpt_auth() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = initialized_app_server(codex_home.path()).await?;
|
||||
|
||||
let consume_id = mcp
|
||||
.send_consume_account_rate_limit_reset_credit_request(
|
||||
ConsumeAccountRateLimitResetCreditParams {
|
||||
idempotency_key: "request-1".to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let consume_error = read_error_response(&mut mcp, consume_id).await?;
|
||||
assert_eq!(consume_error.error.code, INVALID_REQUEST_ERROR_CODE);
|
||||
assert_eq!(
|
||||
consume_error.error.message,
|
||||
"codex account authentication required for rate limit reset credits"
|
||||
);
|
||||
|
||||
login_with_api_key(&mut mcp, "sk-test-key").await?;
|
||||
let consume_id = send_consume_reset_credit(&mut mcp, "request-2").await?;
|
||||
let consume_error = read_error_response(&mut mcp, consume_id).await?;
|
||||
assert_eq!(consume_error.error.code, INVALID_REQUEST_ERROR_CODE);
|
||||
assert_eq!(
|
||||
consume_error.error.message,
|
||||
"chatgpt authentication required for rate limit reset credits"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_account_rate_limit_reset_credit_maps_backend_outcomes() -> Result<()> {
|
||||
let (codex_home, server) = chatgpt_test_context().await?;
|
||||
let cases = [
|
||||
(
|
||||
"request-reset",
|
||||
"reset",
|
||||
ConsumeAccountRateLimitResetCreditOutcome::Reset,
|
||||
2,
|
||||
),
|
||||
(
|
||||
"request-nothing",
|
||||
"nothing_to_reset",
|
||||
ConsumeAccountRateLimitResetCreditOutcome::NothingToReset,
|
||||
0,
|
||||
),
|
||||
(
|
||||
"request-no-credit",
|
||||
"no_credit",
|
||||
ConsumeAccountRateLimitResetCreditOutcome::NoCredit,
|
||||
0,
|
||||
),
|
||||
(
|
||||
"request-retry",
|
||||
"already_redeemed",
|
||||
ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed,
|
||||
0,
|
||||
),
|
||||
];
|
||||
for (idempotency_key, backend_code, _, windows_reset) in cases {
|
||||
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": idempotency_key })))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"code": backend_code,
|
||||
"windows_reset": windows_reset
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut mcp = initialized_app_server(codex_home.path()).await?;
|
||||
for (idempotency_key, _, expected_outcome, _) in cases {
|
||||
assert_eq!(
|
||||
consume_reset_credit(&mut mcp, idempotency_key).await?,
|
||||
ConsumeAccountRateLimitResetCreditResponse {
|
||||
outcome: expected_outcome,
|
||||
}
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key() -> 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: 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, "idempotencyKey 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?;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/rate-limit-reset-credits/consume"))
|
||||
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp = initialized_app_server(codex_home.path()).await?;
|
||||
let request_id = send_consume_reset_credit(&mut mcp, "request-1").await?;
|
||||
let error = read_error_response(&mut mcp, request_id).await?;
|
||||
|
||||
assert_eq!(error.error.code, INTERNAL_ERROR_CODE);
|
||||
assert!(
|
||||
error
|
||||
.error
|
||||
.message
|
||||
.contains("failed to consume rate limit reset"),
|
||||
"unexpected error message: {}",
|
||||
error.error.message
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_timeout_releases_account_auth_queue() -> Result<()> {
|
||||
let (codex_home, server) = chatgpt_test_context().await?;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/rate-limit-reset-credits/consume"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(std::time::Duration::from_secs(/*secs*/ 1))
|
||||
.set_body_json(json!({ "code": "reset", "windows_reset": 2 })),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp = TestAppServer::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("OPENAI_API_KEY", None),
|
||||
(RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR, Some("100")),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
let consume_id = send_consume_reset_credit(&mut mcp, "request-timeout").await?;
|
||||
let account_id = mcp
|
||||
.send_get_account_request(GetAccountParams {
|
||||
refresh_token: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let consume_error: JSONRPCError = timeout(
|
||||
SERVER_TIMEOUT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(consume_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(consume_error.error.code, INTERNAL_ERROR_CODE);
|
||||
assert_eq!(
|
||||
consume_error.error.message,
|
||||
"rate limit reset consume timed out"
|
||||
);
|
||||
|
||||
let account_error = read_error_response(&mut mcp, account_id).await?;
|
||||
assert_eq!(account_error.error.code, INVALID_REQUEST_ERROR_CODE);
|
||||
assert_eq!(
|
||||
account_error.error.message,
|
||||
"email and plan type are required for chatgpt authentication"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn chatgpt_test_context() -> Result<(TempDir, MockServer)> {
|
||||
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())?;
|
||||
Ok((codex_home, server))
|
||||
}
|
||||
|
||||
async fn initialized_app_server(codex_home: &Path) -> Result<TestAppServer> {
|
||||
let mut mcp = TestAppServer::new_with_env(codex_home, &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
Ok(mcp)
|
||||
}
|
||||
|
||||
async fn consume_reset_credit(
|
||||
mcp: &mut TestAppServer,
|
||||
idempotency_key: &str,
|
||||
) -> Result<ConsumeAccountRateLimitResetCreditResponse> {
|
||||
let request_id = send_consume_reset_credit(mcp, idempotency_key).await?;
|
||||
read_response(mcp, request_id).await
|
||||
}
|
||||
|
||||
async fn send_consume_reset_credit(mcp: &mut TestAppServer, idempotency_key: &str) -> Result<i64> {
|
||||
mcp.send_consume_account_rate_limit_reset_credit_request(
|
||||
ConsumeAccountRateLimitResetCreditParams {
|
||||
idempotency_key: idempotency_key.to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn read_response<T>(mcp: &mut TestAppServer, request_id: i64) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
to_response(response)
|
||||
}
|
||||
|
||||
async fn read_error_response(mcp: &mut TestAppServer, request_id: i64) -> Result<JSONRPCError> {
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
Ok(error)
|
||||
}
|
||||
|
||||
async fn login_with_api_key(mcp: &mut TestAppServer, api_key: &str) -> Result<()> {
|
||||
let request_id = mcp.send_login_account_api_key_request(api_key).await?;
|
||||
assert_eq!(
|
||||
read_response::<LoginAccountResponse>(mcp, request_id).await?,
|
||||
LoginAccountResponse::ApiKey {}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_chatgpt_base_url(codex_home: &Path, base_url: &str) -> std::io::Result<()> {
|
||||
std::fs::write(
|
||||
codex_home.join("config.toml"),
|
||||
format!("chatgpt_base_url = \"{base_url}\"\n"),
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ 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::RateLimitResetCreditsSummary;
|
||||
use codex_app_server_protocol::RateLimitSnapshot;
|
||||
use codex_app_server_protocol::RateLimitWindow;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
@@ -157,7 +158,8 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"rate_limit_reset_credits": { "available_count": 3 }
|
||||
});
|
||||
|
||||
Mock::given(method("GET"))
|
||||
@@ -165,6 +167,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(response_body))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
@@ -257,6 +260,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
rate_limit_reset_credits: Some(RateLimitResetCreditsSummary { available_count: 3 }),
|
||||
};
|
||||
assert_eq!(received, expected);
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::fmt;
|
||||
|
||||
mod rate_limit_resets;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RequestError {
|
||||
UnexpectedStatus {
|
||||
@@ -294,14 +296,7 @@ impl Client {
|
||||
}
|
||||
|
||||
pub async fn get_rate_limits_many(&self) -> Result<Vec<RateLimitSnapshot>> {
|
||||
let url = match self.path_style {
|
||||
PathStyle::CodexApi => format!("{}/api/codex/usage", self.base_url),
|
||||
PathStyle::ChatGptApi => format!("{}/wham/usage", self.base_url),
|
||||
};
|
||||
let req = self.http.get(&url).headers(self.headers());
|
||||
let (body, ct) = self.exec_request(req, "GET", &url).await?;
|
||||
let payload: RateLimitStatusPayload = self.decode_json(&url, &ct, &body)?;
|
||||
Ok(Self::rate_limit_snapshots_from_payload(payload))
|
||||
Ok(self.get_rate_limits_with_reset_credits().await?.rate_limits)
|
||||
}
|
||||
|
||||
pub async fn get_accounts_check(&self) -> Result<AccountsCheckResponse> {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Backend client operations for reading available rate-limit reset credits and consuming one.
|
||||
|
||||
use super::Client;
|
||||
use super::PathStyle;
|
||||
use crate::types::ConsumeRateLimitResetCreditResponse;
|
||||
use crate::types::RateLimitStatusWithResetCredits;
|
||||
use crate::types::RateLimitsWithResetCredits;
|
||||
use anyhow::Result;
|
||||
use reqwest::header::CONTENT_TYPE;
|
||||
use reqwest::header::HeaderValue;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ConsumeRateLimitResetCreditRequest<'a> {
|
||||
redeem_request_id: &'a str,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub async fn get_rate_limits_with_reset_credits(&self) -> Result<RateLimitsWithResetCredits> {
|
||||
let payload = self.get_rate_limit_status().await?;
|
||||
Ok(RateLimitsWithResetCredits {
|
||||
rate_limits: Self::rate_limit_snapshots_from_payload(payload.rate_limits),
|
||||
rate_limit_reset_credits: payload.rate_limit_reset_credits,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn get_rate_limit_status(&self) -> Result<RateLimitStatusWithResetCredits> {
|
||||
let url = self.rate_limit_status_url();
|
||||
let req = self.http.get(&url).headers(self.headers());
|
||||
let (body, ct) = self.exec_request(req, "GET", &url).await?;
|
||||
self.decode_json(&url, &ct, &body)
|
||||
}
|
||||
|
||||
pub async fn consume_rate_limit_reset_credit(
|
||||
&self,
|
||||
redeem_request_id: &str,
|
||||
) -> Result<ConsumeRateLimitResetCreditResponse> {
|
||||
let url = self.consume_rate_limit_reset_credit_url();
|
||||
let req = self
|
||||
.http
|
||||
.post(&url)
|
||||
.headers(self.headers())
|
||||
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
|
||||
.json(&ConsumeRateLimitResetCreditRequest { redeem_request_id });
|
||||
let (body, ct) = self.exec_request(req, "POST", &url).await?;
|
||||
self.decode_json(&url, &ct, &body)
|
||||
}
|
||||
|
||||
fn rate_limit_status_url(&self) -> String {
|
||||
match self.path_style {
|
||||
PathStyle::CodexApi => format!("{}/api/codex/usage", self.base_url),
|
||||
PathStyle::ChatGptApi => format!("{}/wham/usage", self.base_url),
|
||||
}
|
||||
}
|
||||
|
||||
fn consume_rate_limit_reset_credit_url(&self) -> String {
|
||||
match self.path_style {
|
||||
PathStyle::CodexApi => {
|
||||
format!(
|
||||
"{}/api/codex/rate-limit-reset-credits/consume",
|
||||
self.base_url
|
||||
)
|
||||
}
|
||||
PathStyle::ChatGptApi => {
|
||||
format!("{}/wham/rate-limit-reset-credits/consume", self.base_url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "rate_limit_resets_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,71 @@
|
||||
use super::*;
|
||||
use crate::types::ConsumeRateLimitResetCreditCode;
|
||||
use crate::types::RateLimitResetCreditsSummary;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
|
||||
assert_eq!(
|
||||
test_client("https://example.test", PathStyle::CodexApi).rate_limit_status_url(),
|
||||
"https://example.test/api/codex/usage"
|
||||
);
|
||||
assert_eq!(
|
||||
test_client("https://example.test", PathStyle::CodexApi)
|
||||
.consume_rate_limit_reset_credit_url(),
|
||||
"https://example.test/api/codex/rate-limit-reset-credits/consume"
|
||||
);
|
||||
assert_eq!(
|
||||
test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi)
|
||||
.rate_limit_status_url(),
|
||||
"https://chatgpt.com/backend-api/wham/usage"
|
||||
);
|
||||
assert_eq!(
|
||||
test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi)
|
||||
.consume_rate_limit_reset_credit_url(),
|
||||
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(ConsumeRateLimitResetCreditRequest {
|
||||
redeem_request_id: "redeem-123",
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::json!({ "redeem_request_id": "redeem-123" })
|
||||
);
|
||||
|
||||
let status: RateLimitStatusWithResetCredits = serde_json::from_value(serde_json::json!({
|
||||
"plan_type": "plus",
|
||||
"rate_limit_reset_credits": { "available_count": 3 }
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status.rate_limit_reset_credits,
|
||||
Some(RateLimitResetCreditsSummary { available_count: 3 })
|
||||
);
|
||||
|
||||
let response: ConsumeRateLimitResetCreditResponse = serde_json::from_value(serde_json::json!({
|
||||
"code": "reset",
|
||||
"credit": { "id": "ignored-by-cli" },
|
||||
"windows_reset": 2
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response,
|
||||
ConsumeRateLimitResetCreditResponse {
|
||||
code: ConsumeRateLimitResetCreditCode::Reset,
|
||||
windows_reset: 2,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn test_client(base_url: &str, path_style: PathStyle) -> Client {
|
||||
Client {
|
||||
base_url: base_url.to_string(),
|
||||
http: reqwest::Client::new(),
|
||||
auth_provider: codex_model_provider::unauthenticated_auth_provider(),
|
||||
user_agent: None,
|
||||
chatgpt_account_id: None,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
path_style,
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,14 @@ pub use types::AccountsCheckResponse;
|
||||
pub use types::CodeTaskDetailsResponse;
|
||||
pub use types::CodeTaskDetailsResponseExt;
|
||||
pub use types::ConfigBundleResponse;
|
||||
pub use types::ConsumeRateLimitResetCreditCode;
|
||||
pub use types::ConsumeRateLimitResetCreditResponse;
|
||||
pub use types::DeliveredConfigToml;
|
||||
pub use types::DeliveredRequirementsToml;
|
||||
pub use types::DeliveredTomlFragment;
|
||||
pub use types::PaginatedListTaskListItem;
|
||||
pub use types::RateLimitResetCreditsSummary;
|
||||
pub use types::RateLimitsWithResetCredits;
|
||||
pub use types::TaskListItem;
|
||||
pub use types::TokenUsageProfile;
|
||||
pub use types::TokenUsageProfileDailyBucket;
|
||||
|
||||
@@ -12,11 +12,46 @@ pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot;
|
||||
pub use codex_backend_openapi_models::models::SpendControlLimitDetails;
|
||||
pub use codex_backend_openapi_models::models::TaskListItem;
|
||||
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use serde::Deserialize;
|
||||
use serde::de::Deserializer;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
pub struct RateLimitResetCreditsSummary {
|
||||
pub available_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RateLimitsWithResetCredits {
|
||||
pub rate_limits: Vec<RateLimitSnapshot>,
|
||||
pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
pub(crate) struct RateLimitStatusWithResetCredits {
|
||||
#[serde(flatten)]
|
||||
pub rate_limits: RateLimitStatusPayload,
|
||||
pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConsumeRateLimitResetCreditCode {
|
||||
Reset,
|
||||
NothingToReset,
|
||||
NoCredit,
|
||||
AlreadyRedeemed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
pub struct ConsumeRateLimitResetCreditResponse {
|
||||
pub code: ConsumeRateLimitResetCreditCode,
|
||||
#[serde(default)]
|
||||
pub windows_reset: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AccountsCheckResponse {
|
||||
pub accounts: Vec<AccountEntry>,
|
||||
|
||||
@@ -1792,6 +1792,7 @@ mod tests {
|
||||
("codex".to_string(), rate_limit_snapshot("codex")),
|
||||
("other".to_string(), rate_limit_snapshot("other")),
|
||||
])),
|
||||
rate_limit_reset_credits: None,
|
||||
};
|
||||
|
||||
let snapshots = app_server_rate_limit_snapshots(response);
|
||||
|
||||
Reference in New Issue
Block a user