mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: show enterprise monthly credit limits in status (#24812)
## Summary
Enterprise users can have an effective monthly credit limit, but Codex
`/status` currently drops that metadata from the account-usage response.
This change adds the optional `spend_control.individual_limit`
projection to the existing rate-limit snapshot flow. The backend client
reads the monthly limit, app-server exposes it as `individualLimit`, and
the TUI renders a `Monthly credit limit` row through the existing
progress-bar renderer.
When the backend does not return an effective monthly limit, existing
rate-limit behavior is unchanged.
## Existing backend state
The account-usage backend already returns the effective monthly limit
and current usage together:
```json
{
"spend_control": {
"reached": false,
"individual_limit": {
"limit": "25000",
"used": "8000",
"remaining": "17000",
"used_percent": 32,
"remaining_percent": 68,
"reset_after_seconds": 86400,
"reset_at": 1778137680
}
}
}
```
Before this change, Codex projected rolling `primary` and `secondary`
windows plus `credits`. It ignored `spend_control.individual_limit`, so
app-server clients and `/status` could not render the monthly cap.
The updated flow is:
```text
account usage backend
-> backend-client reads spend_control.individual_limit
-> existing rate-limit snapshot carries optional individual_limit
-> app-server exposes optional individualLimit
-> TUI renders Monthly credit limit
```
## App-server contract
`account/rateLimits/read` and sparse `account/rateLimits/updated`
notifications now include an additive nullable
`rateLimits.individualLimit` field:
```json
{
"individualLimit": {
"limit": "25000",
"used": "8000",
"remainingPercent": 68,
"resetsAt": 1778137680
}
}
```
In an `account/rateLimits/read` response, `null` means no monthly limit
is available. `account/rateLimits/updated` remains a sparse rolling
notification: clients merge available values into their most recent
`account/rateLimits/read` snapshot or refetch. Nullable account metadata
in a rolling notification does not clear a previously observed value.
## Design decisions
- Extend the existing rate-limit snapshot instead of introducing a
separate request or wire-level update protocol.
- Keep the Codex projection narrow: `/status` needs the effective limit,
current usage, remaining percentage, and reset timestamp.
- Render the monthly row through the existing progress-bar renderer,
with one optional detail line for `8,000 of 25,000 credits used`.
- Keep the backend response optional so existing accounts and older
usage states preserve their current behavior.
- Preserve cached monthly metadata when sparse rolling notifications
omit it. Live account-usage reads remain authoritative and can clear a
removed limit.
## Visual evidence
```text
Monthly credit limit: [██████████████░░░░░░] 68% left (resets 07:08 on 7 May)
8,000 of 25,000 credits used
```
Snapshot:
`codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_enterprise_monthly_credit_limit.snap`
## Testing
Tests: generated app-server schema verification, protocol tests,
backend-client tests, app-server integration coverage, TUI snapshot
coverage, formatting, and workspace lint cleanup.
This commit is contained in:
committed by
GitHub
Unverified
parent
c955f73078
commit
c8e5db16c9
@@ -29,6 +29,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"AccountRateLimitsUpdatedNotification": {
|
||||
"description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.",
|
||||
"properties": {
|
||||
"rateLimits": {
|
||||
"$ref": "#/definitions/RateLimitSnapshot"
|
||||
@@ -2677,6 +2678,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"individualLimit": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SpendControlLimitSnapshot"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"limitId": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -3135,6 +3146,31 @@
|
||||
"description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.",
|
||||
"type": "object"
|
||||
},
|
||||
"SpendControlLimitSnapshot": {
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "string"
|
||||
},
|
||||
"remainingPercent": {
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetsAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"used": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"limit",
|
||||
"remainingPercent",
|
||||
"resetsAt",
|
||||
"used"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubAgentSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
+36
@@ -5720,6 +5720,7 @@
|
||||
},
|
||||
"AccountRateLimitsUpdatedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.",
|
||||
"properties": {
|
||||
"rateLimits": {
|
||||
"$ref": "#/definitions/v2/RateLimitSnapshot"
|
||||
@@ -13327,6 +13328,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"individualLimit": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/SpendControlLimitSnapshot"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"limitId": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -15269,6 +15280,31 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"SpendControlLimitSnapshot": {
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "string"
|
||||
},
|
||||
"remainingPercent": {
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetsAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"used": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"limit",
|
||||
"remainingPercent",
|
||||
"resetsAt",
|
||||
"used"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubAgentSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
+36
@@ -92,6 +92,7 @@
|
||||
},
|
||||
"AccountRateLimitsUpdatedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.",
|
||||
"properties": {
|
||||
"rateLimits": {
|
||||
"$ref": "#/definitions/RateLimitSnapshot"
|
||||
@@ -9856,6 +9857,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"individualLimit": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SpendControlLimitSnapshot"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"limitId": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -13093,6 +13104,31 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"SpendControlLimitSnapshot": {
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "string"
|
||||
},
|
||||
"remainingPercent": {
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetsAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"used": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"limit",
|
||||
"remainingPercent",
|
||||
"resetsAt",
|
||||
"used"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubAgentSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
+36
@@ -61,6 +61,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"individualLimit": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SpendControlLimitSnapshot"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"limitId": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -141,8 +151,34 @@
|
||||
"usedPercent"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SpendControlLimitSnapshot": {
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "string"
|
||||
},
|
||||
"remainingPercent": {
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetsAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"used": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"limit",
|
||||
"remainingPercent",
|
||||
"resetsAt",
|
||||
"used"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.",
|
||||
"properties": {
|
||||
"rateLimits": {
|
||||
"$ref": "#/definitions/RateLimitSnapshot"
|
||||
|
||||
+35
@@ -61,6 +61,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"individualLimit": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SpendControlLimitSnapshot"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"limitId": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -141,6 +151,31 @@
|
||||
"usedPercent"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SpendControlLimitSnapshot": {
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "string"
|
||||
},
|
||||
"remainingPercent": {
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetsAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"used": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"limit",
|
||||
"remainingPercent",
|
||||
"resetsAt",
|
||||
"used"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
|
||||
+7
@@ -3,4 +3,11 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { RateLimitSnapshot } from "./RateLimitSnapshot";
|
||||
|
||||
/**
|
||||
* Sparse rolling rate-limit update.
|
||||
*
|
||||
* Clients should merge available values into the most recent `account/rateLimits/read` response
|
||||
* or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and
|
||||
* does not clear a previously observed value.
|
||||
*/
|
||||
export type AccountRateLimitsUpdatedNotification = { rateLimits: RateLimitSnapshot, };
|
||||
|
||||
@@ -5,5 +5,6 @@ import type { PlanType } from "../PlanType";
|
||||
import type { CreditsSnapshot } from "./CreditsSnapshot";
|
||||
import type { RateLimitReachedType } from "./RateLimitReachedType";
|
||||
import type { RateLimitWindow } from "./RateLimitWindow";
|
||||
import type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot";
|
||||
|
||||
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, };
|
||||
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, individualLimit: SpendControlLimitSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | 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 SpendControlLimitSnapshot = { limit: string, used: string, remainingPercent: number, resetsAt: number, };
|
||||
@@ -350,6 +350,7 @@ export type { SkillsListEntry } from "./SkillsListEntry";
|
||||
export type { SkillsListParams } from "./SkillsListParams";
|
||||
export type { SkillsListResponse } from "./SkillsListResponse";
|
||||
export type { SortDirection } from "./SortDirection";
|
||||
export type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot";
|
||||
export type { SubagentMigration } from "./SubagentMigration";
|
||||
export type { TerminalInteractionNotification } from "./TerminalInteractionNotification";
|
||||
export type { TextElement } from "./TextElement";
|
||||
|
||||
@@ -6,6 +6,7 @@ use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
|
||||
use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType;
|
||||
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
|
||||
use codex_protocol::protocol::SpendControlLimitSnapshot as CoreSpendControlLimitSnapshot;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -247,6 +248,11 @@ pub struct AccountUpdatedNotification {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
/// Sparse rolling rate-limit update.
|
||||
///
|
||||
/// Clients should merge available values into the most recent `account/rateLimits/read` response
|
||||
/// or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and
|
||||
/// does not clear a previously observed value.
|
||||
pub struct AccountRateLimitsUpdatedNotification {
|
||||
pub rate_limits: RateLimitSnapshot,
|
||||
}
|
||||
@@ -260,6 +266,7 @@ pub struct RateLimitSnapshot {
|
||||
pub primary: Option<RateLimitWindow>,
|
||||
pub secondary: Option<RateLimitWindow>,
|
||||
pub credits: Option<CreditsSnapshot>,
|
||||
pub individual_limit: Option<SpendControlLimitSnapshot>,
|
||||
pub plan_type: Option<PlanType>,
|
||||
pub rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
}
|
||||
@@ -272,6 +279,7 @@ impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
|
||||
primary: value.primary.map(RateLimitWindow::from),
|
||||
secondary: value.secondary.map(RateLimitWindow::from),
|
||||
credits: value.credits.map(CreditsSnapshot::from),
|
||||
individual_limit: value.individual_limit.map(SpendControlLimitSnapshot::from),
|
||||
plan_type: value.plan_type,
|
||||
rate_limit_reached_type: value
|
||||
.rate_limit_reached_type
|
||||
@@ -371,6 +379,28 @@ impl From<CoreCreditsSnapshot> for CreditsSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct SpendControlLimitSnapshot {
|
||||
pub limit: String,
|
||||
pub used: String,
|
||||
pub remaining_percent: i32,
|
||||
#[ts(type = "number")]
|
||||
pub resets_at: i64,
|
||||
}
|
||||
|
||||
impl From<CoreSpendControlLimitSnapshot> for SpendControlLimitSnapshot {
|
||||
fn from(value: CoreSpendControlLimitSnapshot) -> Self {
|
||||
Self {
|
||||
limit: value.limit,
|
||||
used: value.used,
|
||||
remaining_percent: value.remaining_percent,
|
||||
resets_at: value.resets_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -1760,8 +1760,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`, or `null`) and includes the current ChatGPT `planType` when available.
|
||||
- `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify).
|
||||
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change.
|
||||
- `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify).
|
||||
- `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.
|
||||
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`.
|
||||
- `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes for a loaded thread; payload includes `{ name, status, error }` where `status` is `starting`, `ready`, `failed`, or `cancelled`.
|
||||
@@ -1865,6 +1865,7 @@ Field notes:
|
||||
- `windowDurationMins` is the quota window length.
|
||||
- `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.
|
||||
|
||||
### 8) Notify a workspace owner about a limit
|
||||
|
||||
|
||||
@@ -3546,6 +3546,7 @@ mod tests {
|
||||
unlimited: false,
|
||||
balance: Some("5".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
|
||||
@@ -798,6 +798,7 @@ mod tests {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: Some(PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
},
|
||||
@@ -818,6 +819,7 @@ mod tests {
|
||||
},
|
||||
"secondary": null,
|
||||
"credits": null,
|
||||
"individualLimit": null,
|
||||
"planType": "plus",
|
||||
"rateLimitReachedType": null
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use codex_app_server_protocol::RateLimitWindow;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SendAddCreditsNudgeEmailParams;
|
||||
use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse;
|
||||
use codex_app_server_protocol::SpendControlLimitSnapshot;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -128,6 +129,19 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
"rate_limit_reached_type": {
|
||||
"type": "workspace_member_usage_limit_reached",
|
||||
},
|
||||
"spend_control": {
|
||||
"reached": false,
|
||||
"individual_limit": {
|
||||
"source": "workspace_spend_controls",
|
||||
"limit": "25000",
|
||||
"used": "8000",
|
||||
"remaining": "17000",
|
||||
"used_percent": 32,
|
||||
"remaining_percent": 68,
|
||||
"reset_after_seconds": 43200,
|
||||
"reset_at": secondary_reset_timestamp,
|
||||
}
|
||||
},
|
||||
"additional_rate_limits": [
|
||||
{
|
||||
"limit_name": "codex_other",
|
||||
@@ -183,6 +197,12 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
resets_at: Some(secondary_reset_timestamp),
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: Some(SpendControlLimitSnapshot {
|
||||
limit: "25000".to_string(),
|
||||
used: "8000".to_string(),
|
||||
remaining_percent: 68,
|
||||
resets_at: secondary_reset_timestamp,
|
||||
}),
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
rate_limit_reached_type: Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached),
|
||||
},
|
||||
@@ -204,6 +224,12 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
resets_at: Some(secondary_reset_timestamp),
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: Some(SpendControlLimitSnapshot {
|
||||
limit: "25000".to_string(),
|
||||
used: "8000".to_string(),
|
||||
remaining_percent: 68,
|
||||
resets_at: secondary_reset_timestamp,
|
||||
}),
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
rate_limit_reached_type: Some(
|
||||
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
|
||||
@@ -222,6 +248,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ use codex_protocol::protocol::CreditsSnapshot;
|
||||
use codex_protocol::protocol::RateLimitReachedType;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow;
|
||||
use codex_protocol::protocol::SpendControlLimitSnapshot;
|
||||
use reqwest::StatusCode;
|
||||
use reqwest::header::CONTENT_TYPE;
|
||||
use reqwest::header::HeaderMap;
|
||||
@@ -470,11 +471,17 @@ impl Client {
|
||||
.rate_limit_reached_type
|
||||
.flatten()
|
||||
.and_then(|details| Self::map_rate_limit_reached_type(details.kind));
|
||||
let individual_limit = payload
|
||||
.spend_control
|
||||
.flatten()
|
||||
.and_then(|details| details.individual_limit.flatten())
|
||||
.map(|details| Self::map_individual_limit(*details));
|
||||
let mut snapshots = vec![Self::make_rate_limit_snapshot(
|
||||
Some("codex".to_string()),
|
||||
/*limit_name*/ None,
|
||||
payload.rate_limit.flatten().map(|details| *details),
|
||||
payload.credits.flatten().map(|details| *details),
|
||||
individual_limit,
|
||||
plan_type,
|
||||
rate_limit_reached_type,
|
||||
)];
|
||||
@@ -485,6 +492,7 @@ impl Client {
|
||||
Some(details.limit_name),
|
||||
details.rate_limit.flatten().map(|rate_limit| *rate_limit),
|
||||
/*credits*/ None,
|
||||
/*individual_limit*/ None,
|
||||
plan_type,
|
||||
/*rate_limit_reached_type*/ None,
|
||||
)
|
||||
@@ -498,6 +506,7 @@ impl Client {
|
||||
limit_name: Option<String>,
|
||||
rate_limit: Option<crate::types::RateLimitStatusDetails>,
|
||||
credits: Option<crate::types::CreditStatusDetails>,
|
||||
individual_limit: Option<SpendControlLimitSnapshot>,
|
||||
plan_type: Option<AccountPlanType>,
|
||||
rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
) -> RateLimitSnapshot {
|
||||
@@ -514,6 +523,7 @@ impl Client {
|
||||
primary,
|
||||
secondary,
|
||||
credits: Self::map_credits(credits),
|
||||
individual_limit,
|
||||
plan_type,
|
||||
rate_limit_reached_type,
|
||||
}
|
||||
@@ -582,6 +592,17 @@ impl Client {
|
||||
})
|
||||
}
|
||||
|
||||
fn map_individual_limit(
|
||||
details: crate::types::SpendControlLimitDetails,
|
||||
) -> SpendControlLimitSnapshot {
|
||||
SpendControlLimitSnapshot {
|
||||
limit: details.limit,
|
||||
used: details.used,
|
||||
remaining_percent: details.remaining_percent,
|
||||
resets_at: i64::from(details.reset_at),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType {
|
||||
match plan_type {
|
||||
crate::types::PlanType::Free => AccountPlanType::Free,
|
||||
@@ -676,6 +697,23 @@ mod tests {
|
||||
balance: Some(Some("9.99".to_string())),
|
||||
..Default::default()
|
||||
}))),
|
||||
spend_control: Some(Some(Box::new(
|
||||
codex_backend_openapi_models::models::SpendControlStatusDetails {
|
||||
reached: false,
|
||||
individual_limit: Some(Some(Box::new(
|
||||
crate::types::SpendControlLimitDetails {
|
||||
source: None,
|
||||
limit: "25000".to_string(),
|
||||
used: "8000".to_string(),
|
||||
remaining: "17000".to_string(),
|
||||
used_percent: 32,
|
||||
remaining_percent: 68,
|
||||
reset_after_seconds: 3600,
|
||||
reset_at: 789,
|
||||
},
|
||||
))),
|
||||
},
|
||||
))),
|
||||
rate_limit_reached_type: Some(Some(BackendRateLimitReachedType {
|
||||
kind: RateLimitReachedKind::WorkspaceMemberCreditsDepleted,
|
||||
})),
|
||||
@@ -707,6 +745,15 @@ mod tests {
|
||||
snapshots[0].rate_limit_reached_type,
|
||||
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted)
|
||||
);
|
||||
assert_eq!(
|
||||
snapshots[0].individual_limit,
|
||||
Some(SpendControlLimitSnapshot {
|
||||
limit: "25000".to_string(),
|
||||
used: "8000".to_string(),
|
||||
remaining_percent: 68,
|
||||
resets_at: 789,
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other"));
|
||||
assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other"));
|
||||
@@ -715,6 +762,7 @@ mod tests {
|
||||
Some(70.0)
|
||||
);
|
||||
assert_eq!(snapshots[1].credits, None);
|
||||
assert_eq!(snapshots[1].individual_limit, None);
|
||||
assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro));
|
||||
assert_eq!(snapshots[1].rate_limit_reached_type, None);
|
||||
}
|
||||
@@ -730,6 +778,7 @@ mod tests {
|
||||
rate_limit: None,
|
||||
}])),
|
||||
credits: None,
|
||||
spend_control: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
|
||||
@@ -755,6 +804,7 @@ mod tests {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
},
|
||||
@@ -768,6 +818,7 @@ mod tests {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
},
|
||||
@@ -812,6 +863,7 @@ mod tests {
|
||||
plan_type: crate::types::PlanType::Plus,
|
||||
rate_limit: None,
|
||||
credits: None,
|
||||
spend_control: None,
|
||||
additional_rate_limits: None,
|
||||
rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { kind })),
|
||||
};
|
||||
@@ -827,6 +879,7 @@ mod tests {
|
||||
plan_type: crate::types::PlanType::Plus,
|
||||
rate_limit: None,
|
||||
credits: None,
|
||||
spend_control: None,
|
||||
additional_rate_limits: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ pub use codex_backend_openapi_models::models::RateLimitReachedKind;
|
||||
pub use codex_backend_openapi_models::models::RateLimitStatusDetails;
|
||||
pub use codex_backend_openapi_models::models::RateLimitStatusPayload;
|
||||
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 serde::Deserialize;
|
||||
|
||||
@@ -93,6 +93,7 @@ pub fn parse_rate_limit_for_limit(
|
||||
primary,
|
||||
secondary,
|
||||
credits,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
@@ -157,6 +158,7 @@ pub fn parse_rate_limit_event(payload: &str) -> Option<RateLimitSnapshot> {
|
||||
primary,
|
||||
secondary,
|
||||
credits,
|
||||
individual_limit: None,
|
||||
plan_type: event.plan_type,
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
|
||||
@@ -56,3 +56,9 @@ pub use self::rate_limit_window_snapshot::RateLimitWindowSnapshot;
|
||||
|
||||
pub(crate) mod credit_status_details;
|
||||
pub use self::credit_status_details::CreditStatusDetails;
|
||||
|
||||
pub(crate) mod spend_control_limit_details;
|
||||
pub use self::spend_control_limit_details::SpendControlLimitDetails;
|
||||
|
||||
pub(crate) mod spend_control_status_details;
|
||||
pub use self::spend_control_status_details::SpendControlStatusDetails;
|
||||
|
||||
@@ -30,6 +30,13 @@ pub struct RateLimitStatusPayload {
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub credits: Option<Option<Box<models::CreditStatusDetails>>>,
|
||||
#[serde(
|
||||
rename = "spend_control",
|
||||
default,
|
||||
with = "::serde_with::rust::double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub spend_control: Option<Option<Box<models::SpendControlStatusDetails>>>,
|
||||
#[serde(
|
||||
rename = "additional_rate_limits",
|
||||
default,
|
||||
@@ -52,6 +59,7 @@ impl RateLimitStatusPayload {
|
||||
plan_type,
|
||||
rate_limit: None,
|
||||
credits: None,
|
||||
spend_control: None,
|
||||
additional_rate_limits: None,
|
||||
rate_limit_reached_type: None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* codex-backend
|
||||
*
|
||||
* codex-backend
|
||||
*
|
||||
* The version of the OpenAPI document: 0.0.1
|
||||
*
|
||||
* Generated by: https://openapi-generator.tech
|
||||
*/
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpendControlLimitDetails {
|
||||
#[serde(
|
||||
rename = "source",
|
||||
default,
|
||||
with = "::serde_with::rust::double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub source: Option<Option<String>>,
|
||||
#[serde(rename = "limit")]
|
||||
pub limit: String,
|
||||
#[serde(rename = "used")]
|
||||
pub used: String,
|
||||
#[serde(rename = "remaining")]
|
||||
pub remaining: String,
|
||||
#[serde(rename = "used_percent")]
|
||||
pub used_percent: i32,
|
||||
#[serde(rename = "remaining_percent")]
|
||||
pub remaining_percent: i32,
|
||||
#[serde(rename = "reset_after_seconds")]
|
||||
pub reset_after_seconds: i32,
|
||||
#[serde(rename = "reset_at")]
|
||||
pub reset_at: i32,
|
||||
}
|
||||
|
||||
impl SpendControlLimitDetails {
|
||||
pub fn new(
|
||||
limit: String,
|
||||
used: String,
|
||||
remaining: String,
|
||||
used_percent: i32,
|
||||
remaining_percent: i32,
|
||||
reset_after_seconds: i32,
|
||||
reset_at: i32,
|
||||
) -> SpendControlLimitDetails {
|
||||
SpendControlLimitDetails {
|
||||
source: None,
|
||||
limit,
|
||||
used,
|
||||
remaining,
|
||||
used_percent,
|
||||
remaining_percent,
|
||||
reset_after_seconds,
|
||||
reset_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* codex-backend
|
||||
*
|
||||
* codex-backend
|
||||
*
|
||||
* The version of the OpenAPI document: 0.0.1
|
||||
*
|
||||
* Generated by: https://openapi-generator.tech
|
||||
*/
|
||||
|
||||
use crate::models;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpendControlStatusDetails {
|
||||
#[serde(rename = "reached")]
|
||||
pub reached: bool,
|
||||
#[serde(
|
||||
rename = "individual_limit",
|
||||
default,
|
||||
with = "::serde_with::rust::double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub individual_limit: Option<Option<Box<models::SpendControlLimitDetails>>>,
|
||||
}
|
||||
|
||||
impl SpendControlStatusDetails {
|
||||
pub fn new(reached: bool) -> SpendControlStatusDetails {
|
||||
SpendControlStatusDetails {
|
||||
reached,
|
||||
individual_limit: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3118,6 +3118,7 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
unlimited: false,
|
||||
balance: Some("10.00".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -3137,6 +3138,7 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
resets_at: Some(1_900),
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -3150,6 +3152,7 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
primary: update.primary.clone(),
|
||||
secondary: update.secondary,
|
||||
credits: initial.credits,
|
||||
individual_limit: initial.individual_limit,
|
||||
plan_type: initial.plan_type,
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
@@ -3227,6 +3230,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
unlimited: false,
|
||||
balance: Some("15.00".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -3242,6 +3246,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: Some(codex_protocol::account::PlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -3255,6 +3260,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
primary: update.primary,
|
||||
secondary: update.secondary,
|
||||
credits: initial.credits,
|
||||
individual_limit: initial.individual_limit,
|
||||
plan_type: update.plan_type,
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
|
||||
@@ -258,6 +258,9 @@ fn merge_rate_limit_fields(
|
||||
if snapshot.credits.is_none() {
|
||||
snapshot.credits = previous.and_then(|prior| prior.credits.clone());
|
||||
}
|
||||
if snapshot.individual_limit.is_none() {
|
||||
snapshot.individual_limit = previous.and_then(|prior| prior.individual_limit.clone());
|
||||
}
|
||||
if snapshot.plan_type.is_none() {
|
||||
snapshot.plan_type = previous.and_then(|prior| prior.plan_type);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::session::tests::make_session_configuration_for_tests;
|
||||
use crate::state::AutoCompactWindowSnapshot;
|
||||
use codex_protocol::protocol::CreditsSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow;
|
||||
use codex_protocol::protocol::SpendControlLimitSnapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -49,6 +50,7 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
@@ -95,6 +97,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
@@ -108,6 +111,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
@@ -122,7 +126,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other() {
|
||||
async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() {
|
||||
let session_configuration = make_session_configuration_for_tests().await;
|
||||
let mut state = SessionState::new(session_configuration);
|
||||
|
||||
@@ -140,6 +144,12 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other
|
||||
unlimited: false,
|
||||
balance: Some("50".to_string()),
|
||||
}),
|
||||
individual_limit: Some(SpendControlLimitSnapshot {
|
||||
limit: "25000".to_string(),
|
||||
used: "8000".to_string(),
|
||||
remaining_percent: 68,
|
||||
resets_at: 300,
|
||||
}),
|
||||
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
@@ -154,6 +164,7 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
@@ -174,6 +185,12 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other
|
||||
unlimited: false,
|
||||
balance: Some("50".to_string()),
|
||||
}),
|
||||
individual_limit: Some(SpendControlLimitSnapshot {
|
||||
limit: "25000".to_string(),
|
||||
used: "8000".to_string(),
|
||||
remaining_percent: 68,
|
||||
resets_at: 300,
|
||||
}),
|
||||
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
|
||||
@@ -2580,6 +2580,7 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
"resets_at": 1704074400
|
||||
},
|
||||
"credits": null,
|
||||
"individual_limit": null,
|
||||
"plan_type": null,
|
||||
"rate_limit_reached_type": null
|
||||
}
|
||||
@@ -2655,6 +2656,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
|
||||
"resets_at": null
|
||||
},
|
||||
"credits": null,
|
||||
"individual_limit": null,
|
||||
"plan_type": null,
|
||||
"rate_limit_reached_type": null
|
||||
});
|
||||
|
||||
@@ -1350,6 +1350,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() {
|
||||
"resets_at": null
|
||||
},
|
||||
"credits": null,
|
||||
"individual_limit": null,
|
||||
"plan_type": null,
|
||||
"rate_limit_reached_type": null
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ fn snapshot(
|
||||
primary: primary_used_percent.map(window),
|
||||
secondary: secondary_used_percent.map(window),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ fn rate_limit_snapshot() -> RateLimitSnapshot {
|
||||
resets_at: Some(secondary_reset_at),
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}
|
||||
|
||||
@@ -2009,6 +2009,7 @@ pub struct RateLimitSnapshot {
|
||||
pub primary: Option<RateLimitWindow>,
|
||||
pub secondary: Option<RateLimitWindow>,
|
||||
pub credits: Option<CreditsSnapshot>,
|
||||
pub individual_limit: Option<SpendControlLimitSnapshot>,
|
||||
pub plan_type: Option<crate::account::PlanType>,
|
||||
pub rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
}
|
||||
@@ -2058,6 +2059,14 @@ pub struct CreditsSnapshot {
|
||||
pub balance: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct SpendControlLimitSnapshot {
|
||||
pub limit: String,
|
||||
pub used: String,
|
||||
pub remaining_percent: i32,
|
||||
pub resets_at: i64,
|
||||
}
|
||||
|
||||
// Includes prompts, tools and space to call compact.
|
||||
const BASELINE_TOKENS: i64 = 12000;
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ impl App {
|
||||
}
|
||||
ServerNotification::AccountRateLimitsUpdated(notification) => {
|
||||
self.chat_widget
|
||||
.on_rate_limit_snapshot(Some(notification.rate_limits.clone()));
|
||||
.on_rolling_rate_limit_snapshot(notification.rate_limits.clone());
|
||||
return;
|
||||
}
|
||||
ServerNotification::AccountUpdated(notification) => {
|
||||
|
||||
@@ -1804,6 +1804,7 @@ mod tests {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}
|
||||
|
||||
@@ -150,8 +150,27 @@ pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -
|
||||
matches!(info, AppServerCodexErrorInfo::CyberPolicy)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum RateLimitSnapshotSource {
|
||||
AccountUsage,
|
||||
RollingUpdate,
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
|
||||
self.on_rate_limit_snapshot_from(snapshot, RateLimitSnapshotSource::AccountUsage);
|
||||
}
|
||||
|
||||
pub(crate) fn on_rolling_rate_limit_snapshot(&mut self, snapshot: RateLimitSnapshot) {
|
||||
// Rolling app-server notifications are sparse. Preserve metadata learned from the full read.
|
||||
self.on_rate_limit_snapshot_from(Some(snapshot), RateLimitSnapshotSource::RollingUpdate);
|
||||
}
|
||||
|
||||
fn on_rate_limit_snapshot_from(
|
||||
&mut self,
|
||||
snapshot: Option<RateLimitSnapshot>,
|
||||
source: RateLimitSnapshotSource,
|
||||
) {
|
||||
if let Some(mut snapshot) = snapshot {
|
||||
let limit_id = snapshot
|
||||
.limit_id
|
||||
@@ -172,7 +191,16 @@ impl ChatWidget {
|
||||
balance: credits.balance.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let preserved_individual_limit =
|
||||
if matches!(source, RateLimitSnapshotSource::RollingUpdate)
|
||||
&& snapshot.individual_limit.is_none()
|
||||
{
|
||||
self.rate_limit_snapshots_by_limit_id
|
||||
.get(&limit_id)
|
||||
.and_then(|display| display.individual_limit.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.plan_type = snapshot.plan_type.or(self.plan_type);
|
||||
|
||||
let is_codex_limit = limit_id.eq_ignore_ascii_case("codex");
|
||||
@@ -234,8 +262,11 @@ impl ChatWidget {
|
||||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending;
|
||||
}
|
||||
|
||||
let display =
|
||||
let mut display =
|
||||
rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now());
|
||||
if display.individual_limit.is_none() {
|
||||
display.individual_limit = preserved_individual_limit;
|
||||
}
|
||||
self.rate_limit_snapshots_by_limit_id
|
||||
.insert(limit_id, display);
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
use crate::bottom_pane::goal_status_indicator_line;
|
||||
use crate::chatwidget::rate_limits::NUDGE_MODEL_SLUG;
|
||||
use crate::chatwidget::rate_limits::get_limits_duration;
|
||||
use codex_app_server_protocol::SpendControlLimitSnapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::backend::TestBackend;
|
||||
use serial_test::serial;
|
||||
@@ -580,6 +581,7 @@ async fn status_line_uses_secondary_fallback_for_unsupported_window() {
|
||||
resets_at: None,
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -608,6 +610,7 @@ async fn status_line_legacy_limit_items_prefer_matching_windows() {
|
||||
resets_at: None,
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -640,6 +643,7 @@ async fn status_line_shows_secondary_non_weekly_when_primary_is_weekly() {
|
||||
resets_at: None,
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -668,6 +672,7 @@ async fn status_line_five_hour_item_omits_weekly_only_limit() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -696,6 +701,7 @@ async fn status_line_single_monthly_primary_omits_weekly_limit_item() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -724,6 +730,7 @@ async fn status_line_secondary_only_non_weekly_limit_omits_primary_limit_item()
|
||||
resets_at: None,
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -752,6 +759,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
||||
unlimited: false,
|
||||
balance: Some("17.5".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -772,6 +780,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -793,6 +802,40 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rolling_rate_limit_snapshot_preserves_prior_individual_limit() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let mut usage_limits = snapshot(/*percent*/ 10.0);
|
||||
usage_limits.individual_limit = Some(SpendControlLimitSnapshot {
|
||||
limit: "25000".to_string(),
|
||||
used: "8000".to_string(),
|
||||
remaining_percent: 68,
|
||||
resets_at: 1_800_000_000,
|
||||
});
|
||||
chat.on_rate_limit_snapshot(Some(usage_limits));
|
||||
|
||||
chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 20.0));
|
||||
|
||||
let display = chat
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.expect("rate limits should be cached");
|
||||
let individual_limit = display
|
||||
.individual_limit
|
||||
.as_ref()
|
||||
.expect("rolling updates should preserve monthly limits");
|
||||
assert_eq!(individual_limit.used, "8,000");
|
||||
assert_eq!(individual_limit.limit, "25,000");
|
||||
assert_eq!(individual_limit.percent_remaining, 68.0);
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 30.0)));
|
||||
let display = chat
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.expect("rate limits should be cached");
|
||||
assert!(display.individual_limit.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -811,6 +854,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
||||
resets_at: None,
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: Some(PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -830,6 +874,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
||||
resets_at: Some(234),
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: Some(PlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -849,6 +894,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
||||
resets_at: Some(567),
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -873,6 +919,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
|
||||
unlimited: false,
|
||||
balance: Some("5.00".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: Some(PlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -887,6 +934,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: Some(PlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -940,6 +988,7 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
|
||||
@@ -76,6 +76,7 @@ fn cache_rate_limit_snapshot(chat: &mut ChatWidget) {
|
||||
resets_at: None,
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -233,6 +234,7 @@ async fn status_surface_preview_omits_unavailable_rate_limit_items() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
|
||||
@@ -471,6 +471,7 @@ impl StatusHistoryCell {
|
||||
StatusRateLimitValue::Window {
|
||||
percent_used,
|
||||
resets_at,
|
||||
details,
|
||||
} => {
|
||||
let percent_remaining = (100.0 - percent_used).clamp(0.0, 100.0);
|
||||
let summary = format_status_limit_summary(percent_remaining);
|
||||
@@ -522,6 +523,18 @@ impl StatusHistoryCell {
|
||||
} else {
|
||||
lines.push(base_line);
|
||||
}
|
||||
if let Some(details) = details {
|
||||
let detail_width = formatter.value_width(available_inner_width).max(1);
|
||||
let wrap_options = textwrap::Options::new(detail_width).break_words(false);
|
||||
lines.extend(
|
||||
textwrap::wrap(details.as_str(), wrap_options)
|
||||
.into_iter()
|
||||
.map(|wrapped| {
|
||||
formatter
|
||||
.continuation(vec![Span::from(wrapped.into_owned()).dim()])
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
StatusRateLimitValue::Text(text) => {
|
||||
let label = row.label.clone();
|
||||
|
||||
@@ -17,6 +17,8 @@ use chrono::Utc;
|
||||
use codex_app_server_protocol::CreditsSnapshot as CoreCreditsSnapshot;
|
||||
use codex_app_server_protocol::RateLimitSnapshot;
|
||||
use codex_app_server_protocol::RateLimitWindow;
|
||||
use codex_app_server_protocol::SpendControlLimitSnapshot as CoreSpendControlLimitSnapshot;
|
||||
use codex_protocol::num_format::format_with_separators;
|
||||
|
||||
const STATUS_LIMIT_BAR_SEGMENTS: usize = 20;
|
||||
const STATUS_LIMIT_BAR_FILLED: &str = "█";
|
||||
@@ -39,6 +41,8 @@ pub(crate) enum StatusRateLimitValue {
|
||||
percent_used: f64,
|
||||
/// Localized reset string, or `None` when unknown.
|
||||
resets_at: Option<String>,
|
||||
/// Optional detail line rendered beneath the progress bar.
|
||||
details: Option<String>,
|
||||
},
|
||||
/// Plain text value used for non-window rows.
|
||||
Text(String),
|
||||
@@ -99,6 +103,8 @@ pub(crate) struct RateLimitSnapshotDisplay {
|
||||
pub secondary: Option<RateLimitWindowDisplay>,
|
||||
/// Optional credits metadata when available.
|
||||
pub credits: Option<CreditsSnapshotDisplay>,
|
||||
/// Optional effective monthly credit limit from workspace spend controls.
|
||||
pub individual_limit: Option<SpendControlLimitSnapshotDisplay>,
|
||||
}
|
||||
|
||||
/// Display-ready credits state extracted from protocol snapshots.
|
||||
@@ -112,6 +118,17 @@ pub(crate) struct CreditsSnapshotDisplay {
|
||||
pub balance: Option<String>,
|
||||
}
|
||||
|
||||
/// Display-ready effective monthly limit extracted from spend controls.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SpendControlLimitSnapshotDisplay {
|
||||
/// Local timestamp representing when this monthly usage value was captured.
|
||||
pub captured_at: DateTime<Local>,
|
||||
pub percent_remaining: f64,
|
||||
pub used: String,
|
||||
pub limit: String,
|
||||
pub resets_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Converts a protocol snapshot into UI-friendly display data.
|
||||
///
|
||||
/// Pass the timestamp from the same observation point as `snapshot`; supplying a significantly
|
||||
@@ -141,6 +158,10 @@ pub(crate) fn rate_limit_snapshot_display_for_limit(
|
||||
.as_ref()
|
||||
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
|
||||
credits: snapshot.credits.as_ref().map(CreditsSnapshotDisplay::from),
|
||||
individual_limit: snapshot
|
||||
.individual_limit
|
||||
.as_ref()
|
||||
.and_then(|limit| SpendControlLimitSnapshotDisplay::from_limit(limit, captured_at)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +175,22 @@ impl From<&CoreCreditsSnapshot> for CreditsSnapshotDisplay {
|
||||
}
|
||||
}
|
||||
|
||||
impl SpendControlLimitSnapshotDisplay {
|
||||
fn from_limit(
|
||||
value: &CoreSpendControlLimitSnapshot,
|
||||
captured_at: DateTime<Local>,
|
||||
) -> Option<Self> {
|
||||
Some(Self {
|
||||
captured_at,
|
||||
percent_remaining: f64::from(value.remaining_percent.clamp(0, 100)),
|
||||
used: format_credit_amount(&value.used)?,
|
||||
limit: format_credit_amount(&value.limit)?,
|
||||
resets_at: DateTime::<Utc>::from_timestamp(value.resets_at, 0)
|
||||
.map(|dt| format_reset_timestamp(dt.with_timezone(&Local), captured_at)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds display rows from a snapshot and marks stale data by capture age.
|
||||
///
|
||||
/// Callers should pass `Local::now()` for `now` at render time; using a cached timestamp can make
|
||||
@@ -182,6 +219,14 @@ pub(crate) fn compose_rate_limit_data_many(
|
||||
for snapshot in snapshots {
|
||||
stale |= now.signed_duration_since(snapshot.captured_at)
|
||||
> ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES);
|
||||
stale |= snapshot
|
||||
.individual_limit
|
||||
.as_ref()
|
||||
.map(|limit| {
|
||||
now.signed_duration_since(limit.captured_at)
|
||||
> ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let limit_bucket_label = snapshot.limit_name.clone();
|
||||
let show_limit_prefix = !limit_bucket_label.eq_ignore_ascii_case("codex");
|
||||
@@ -230,6 +275,7 @@ pub(crate) fn compose_rate_limit_data_many(
|
||||
value: StatusRateLimitValue::Window {
|
||||
percent_used: primary.used_percent,
|
||||
resets_at: primary.resets_at.clone(),
|
||||
details: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -256,6 +302,7 @@ pub(crate) fn compose_rate_limit_data_many(
|
||||
value: StatusRateLimitValue::Window {
|
||||
percent_used: secondary.used_percent,
|
||||
resets_at: secondary.resets_at.clone(),
|
||||
details: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -265,6 +312,19 @@ pub(crate) fn compose_rate_limit_data_many(
|
||||
{
|
||||
rows.push(row);
|
||||
}
|
||||
if let Some(individual_limit) = snapshot.individual_limit.as_ref() {
|
||||
rows.push(StatusRateLimitRow {
|
||||
label: "Monthly credit limit".to_string(),
|
||||
value: StatusRateLimitValue::Window {
|
||||
percent_used: 100.0 - individual_limit.percent_remaining,
|
||||
resets_at: individual_limit.resets_at.clone(),
|
||||
details: Some(format!(
|
||||
"{} of {} credits used",
|
||||
individual_limit.used, individual_limit.limit
|
||||
)),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if rows.is_empty() {
|
||||
@@ -341,6 +401,14 @@ fn format_credit_balance(raw: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn format_credit_amount(raw: &str) -> Option<String> {
|
||||
let value = raw.trim().parse::<f64>().ok()?;
|
||||
if !value.is_finite() || value < 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(format_with_separators(value.round() as i64))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::CreditsSnapshotDisplay;
|
||||
@@ -372,6 +440,7 @@ mod tests {
|
||||
unlimited: false,
|
||||
balance: Some("25".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
};
|
||||
let other = RateLimitSnapshotDisplay {
|
||||
limit_name: "codex-other".to_string(),
|
||||
@@ -383,6 +452,7 @@ mod tests {
|
||||
unlimited: false,
|
||||
balance: Some("99".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
};
|
||||
|
||||
let rows = match compose_rate_limit_data_many(&[codex, other], now) {
|
||||
@@ -420,6 +490,7 @@ mod tests {
|
||||
window_minutes: Some(2 * 60),
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
};
|
||||
|
||||
let rows = match compose_rate_limit_data_many(&[other], now) {
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
---
|
||||
source: tui/src/status/tests.rs
|
||||
expression: sanitized
|
||||
---
|
||||
/status
|
||||
|
||||
╭───────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │
|
||||
│ information on rate limits and credits │
|
||||
│ │
|
||||
│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Permissions: Custom (workspace with network access, Ask for approval) │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 1.2K total (800 input + 400 output) │
|
||||
│ Context window: 100% left (1.2K used / 272K) │
|
||||
│ Monthly credit limit: [██████████████░░░░░░] 68% left (resets 07:08 on 7 May) │
|
||||
│ 8,000 of 25,000 credits used │
|
||||
╰───────────────────────────────────────────────────────────────────────────────────╯
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
---
|
||||
source: tui/src/status/tests.rs
|
||||
expression: sanitized
|
||||
---
|
||||
/status
|
||||
|
||||
╭────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Visit │
|
||||
│ https://chatgpt.com/codex/settings/usage │
|
||||
│ for up-to-date │
|
||||
│ information on rate limits and credits │
|
||||
│ │
|
||||
│ Model: gpt-5.1-codex-max │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Permissions: Custom (workspace │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 1.2K total (800 │
|
||||
│ Context window: 100% left (1.2K u │
|
||||
│ Monthly credit limit: 68% left │
|
||||
│ (resets 07:08 on │
|
||||
│ 7 May) │
|
||||
│ 8,000 of 25,000 │
|
||||
│ credits used │
|
||||
╰────────────────────────────────────────────╯
|
||||
@@ -2,6 +2,11 @@ use super::new_status_output;
|
||||
use super::new_status_output_with_rate_limits;
|
||||
use super::new_status_output_with_rate_limits_handle;
|
||||
use super::rate_limit_snapshot_display;
|
||||
use super::rate_limits::RateLimitSnapshotDisplay;
|
||||
use super::rate_limits::RateLimitWindowDisplay;
|
||||
use super::rate_limits::SpendControlLimitSnapshotDisplay;
|
||||
use super::rate_limits::StatusRateLimitData;
|
||||
use super::rate_limits::compose_rate_limit_data_many;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::legacy_core::config::ConfigBuilder;
|
||||
@@ -13,12 +18,14 @@ use crate::test_support::test_path_buf;
|
||||
use crate::token_usage::TokenUsage;
|
||||
use crate::token_usage::TokenUsageInfo;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use chrono::Local;
|
||||
use chrono::TimeZone;
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_app_server_protocol::CreditsSnapshot;
|
||||
use codex_app_server_protocol::RateLimitSnapshot;
|
||||
use codex_app_server_protocol::RateLimitWindow;
|
||||
use codex_app_server_protocol::SpendControlLimitSnapshot;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_model_provider_info::ModelProviderAwsAuthInfo;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
@@ -40,6 +47,35 @@ use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::prelude::*;
|
||||
use tempfile::TempDir;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
#[test]
|
||||
fn stale_monthly_limit_marks_fresh_rolling_snapshot_stale() {
|
||||
let now = Local::now();
|
||||
let snapshot = RateLimitSnapshotDisplay {
|
||||
limit_name: "codex".to_string(),
|
||||
captured_at: now,
|
||||
primary: Some(RateLimitWindowDisplay {
|
||||
used_percent: 20.0,
|
||||
resets_at: Some("soon".to_string()),
|
||||
window_minutes: Some(300),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: Some(SpendControlLimitSnapshotDisplay {
|
||||
captured_at: now - ChronoDuration::minutes(20),
|
||||
percent_remaining: 68.0,
|
||||
used: "8,000".to_string(),
|
||||
limit: "25,000".to_string(),
|
||||
resets_at: Some("later".to_string()),
|
||||
}),
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
compose_rate_limit_data_many(&[snapshot], now),
|
||||
StatusRateLimitData::Stale(_)
|
||||
));
|
||||
}
|
||||
|
||||
fn app_server_workspace_write_profile(network_enabled: bool) -> PermissionProfile {
|
||||
PermissionProfile::Managed {
|
||||
@@ -133,18 +169,27 @@ fn render_lines(lines: &[Line<'static>]) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn sanitize_directory(lines: Vec<String>) -> Vec<String> {
|
||||
let frame_width = lines
|
||||
.iter()
|
||||
.find(|line| line.starts_with('╭'))
|
||||
.map(|line| UnicodeWidthStr::width(line.as_str()));
|
||||
lines
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
if let (Some(dir_pos), Some(pipe_idx)) = (line.find("Directory: "), line.rfind('│')) {
|
||||
if let (Some(frame_width), Some(dir_pos), Some(pipe_idx)) =
|
||||
(frame_width, line.find("Directory: "), line.rfind('│'))
|
||||
{
|
||||
let prefix = &line[..dir_pos + "Directory: ".len()];
|
||||
let suffix = &line[pipe_idx..];
|
||||
let content_width = pipe_idx.saturating_sub(dir_pos + "Directory: ".len());
|
||||
let replacement = "[[workspace]]";
|
||||
let content_width = frame_width.saturating_sub(
|
||||
UnicodeWidthStr::width(prefix) + UnicodeWidthStr::width(suffix),
|
||||
);
|
||||
let mut rebuilt = prefix.to_string();
|
||||
rebuilt.push_str(replacement);
|
||||
if content_width > replacement.len() {
|
||||
rebuilt.push_str(&" ".repeat(content_width - replacement.len()));
|
||||
let replacement_width = UnicodeWidthStr::width(replacement);
|
||||
if content_width > replacement_width {
|
||||
rebuilt.push_str(&" ".repeat(content_width - replacement_width));
|
||||
}
|
||||
rebuilt.push_str(suffix);
|
||||
rebuilt
|
||||
@@ -236,6 +281,7 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_200)),
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -871,6 +917,7 @@ async fn status_snapshot_includes_monthly_limit() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -903,6 +950,82 @@ async fn status_snapshot_includes_monthly_limit() {
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_includes_enterprise_monthly_credit_limit() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let mut config = test_config(&temp_home).await;
|
||||
config.model = Some("gpt-5.1-codex-max".to_string());
|
||||
config.model_provider_id = "openai".to_string();
|
||||
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
|
||||
|
||||
let account_display = test_status_account_display();
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 800,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 400,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 1_200,
|
||||
};
|
||||
let captured_at = chrono::Local
|
||||
.with_ymd_and_hms(2024, 5, 6, 7, 8, 9)
|
||||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: Some(SpendControlLimitSnapshot {
|
||||
limit: "25000".to_string(),
|
||||
used: "8000".to_string(),
|
||||
remaining_percent: 68,
|
||||
resets_at: reset_at_from(&captured_at, /*seconds*/ 86_400),
|
||||
}),
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
|
||||
let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref());
|
||||
let token_info = token_info_for(&model_slug, &config, &usage);
|
||||
let composite = new_status_output(
|
||||
&config,
|
||||
account_display.as_ref(),
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
/*thread_name*/ None,
|
||||
/*forked_from*/ None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
&model_slug,
|
||||
/*collaboration_mode*/ None,
|
||||
/*reasoning_effort_override*/ None,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 92));
|
||||
if cfg!(windows) {
|
||||
for line in &mut rendered_lines {
|
||||
*line = line.replace('\\', "/");
|
||||
}
|
||||
}
|
||||
let sanitized = sanitize_directory(rendered_lines).join("\n");
|
||||
assert_snapshot!(sanitized);
|
||||
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 46));
|
||||
if cfg!(windows) {
|
||||
for line in &mut rendered_lines {
|
||||
*line = line.replace('\\', "/");
|
||||
}
|
||||
}
|
||||
let sanitized = sanitize_directory(rendered_lines).join("\n");
|
||||
assert_snapshot!(
|
||||
"status_snapshot_wraps_enterprise_monthly_credit_details_in_narrow_terminal",
|
||||
sanitized
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
@@ -938,6 +1061,7 @@ async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() {
|
||||
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 172_800)),
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -990,6 +1114,7 @@ async fn status_snapshot_shows_unlimited_credits() {
|
||||
unlimited: true,
|
||||
balance: None,
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1040,6 +1165,7 @@ async fn status_snapshot_shows_positive_credits() {
|
||||
unlimited: false,
|
||||
balance: Some("12.5".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1090,6 +1216,7 @@ async fn status_snapshot_hides_zero_credits() {
|
||||
unlimited: false,
|
||||
balance: Some("0".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1138,6 +1265,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
|
||||
unlimited: true,
|
||||
balance: None,
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1244,6 +1372,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1414,6 +1543,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() {
|
||||
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)),
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1485,6 +1615,7 @@ async fn status_snapshot_includes_credits_and_limits() {
|
||||
unlimited: false,
|
||||
balance: Some("37.5".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1539,6 +1670,7 @@ async fn status_snapshot_shows_unavailable_limits_message() {
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1596,6 +1728,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() {
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1667,6 +1800,7 @@ async fn status_snapshot_shows_stale_limits_message() {
|
||||
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_800)),
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1738,6 +1872,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
|
||||
unlimited: false,
|
||||
balance: Some("80".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user