mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Honor workspace spend controls in rate-limit handling (#33187)
## Why Sparse and out-of-order rate-limit updates can omit account metadata or let an older full read overwrite a newer workspace hard stop. Credit availability also does not always include a displayable balance. ## What changed - Propagate the backend's `spendControlReached` state through rate-limit snapshots and the app-server protocol, preserving it across sparse updates. - Keep workspace hard-stop classifications for error routing and ignore stale full-read results after a newer hard-stop notification. - Base TUI warnings and model-switch prompts on credit availability and hard-stop state, and show `Credits: Available` when a finite balance is hidden. ## Testing Added coverage for spend-control mapping, snapshot merging, out-of-order reads, workspace error routing, warning and prompt behavior, and credit status display. GitOrigin-RevId: 18b09d493d5a620a7c079fb721ec50b38dd5b04c
This commit is contained in:
committed by
copyberry
parent
be0e0d791a
commit
4df8027a97
@@ -3070,6 +3070,13 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spendControlReached": {
|
||||
"description": "Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.",
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
+7
@@ -14823,6 +14823,13 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spendControlReached": {
|
||||
"description": "Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.",
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
+7
@@ -11220,6 +11220,13 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spendControlReached": {
|
||||
"description": "Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.",
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
+7
@@ -122,6 +122,13 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spendControlReached": {
|
||||
"description": "Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.",
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
+7
@@ -208,6 +208,13 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spendControlReached": {
|
||||
"description": "Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.",
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -7,4 +7,8 @@ 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, individualLimit: SpendControlLimitSnapshot | 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,
|
||||
/**
|
||||
* Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.
|
||||
*/
|
||||
spendControlReached: boolean | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, };
|
||||
|
||||
@@ -532,6 +532,8 @@ pub struct RateLimitSnapshot {
|
||||
pub secondary: Option<RateLimitWindow>,
|
||||
pub credits: Option<CreditsSnapshot>,
|
||||
pub individual_limit: Option<SpendControlLimitSnapshot>,
|
||||
/// Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.
|
||||
pub spend_control_reached: Option<bool>,
|
||||
pub plan_type: Option<PlanType>,
|
||||
pub rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
}
|
||||
@@ -545,6 +547,7 @@ impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
|
||||
secondary: value.secondary.map(RateLimitWindow::from),
|
||||
credits: value.credits.map(CreditsSnapshot::from),
|
||||
individual_limit: value.individual_limit.map(SpendControlLimitSnapshot::from),
|
||||
spend_control_reached: value.spend_control_reached,
|
||||
plan_type: value.plan_type,
|
||||
rate_limit_reached_type: value
|
||||
.rate_limit_reached_type
|
||||
|
||||
@@ -1939,11 +1939,12 @@ 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` on success.
|
||||
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `bedrockApiKey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available.
|
||||
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit data is snapshot-only.
|
||||
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, whether spend control has been reached, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit data is snapshot-only.
|
||||
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key, optionally selecting a reset-credit ID returned by `account/rateLimits/read`.
|
||||
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
|
||||
- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available.
|
||||
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
|
||||
`spendControlReached` is `true` or `false` when the backend reports spend-control state; `null` means unavailable and must not clear a previously observed value in a sparse update.
|
||||
- `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, threadId, success, error? }`.
|
||||
- `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes; payload includes `{ threadId, name, status, error, failureReason }`, where `threadId` is the owning thread when startup is thread-scoped and `null` when it is app-scoped, and `status` is `starting`, `ready`, `failed`, or `cancelled`. `failureReason` is `reauthenticationRequired` when stored OAuth credentials have expired and cannot be refreshed, so clients can prompt the user to reconnect the named server.
|
||||
|
||||
@@ -3715,6 +3715,7 @@ mod tests {
|
||||
balance: Some("5".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
|
||||
@@ -838,6 +838,7 @@ mod tests {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: Some(PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
},
|
||||
@@ -858,6 +859,7 @@ mod tests {
|
||||
"secondary": null,
|
||||
"credits": null,
|
||||
"individualLimit": null,
|
||||
"spendControlReached": null,
|
||||
"planType": "plus",
|
||||
"rateLimitReachedType": null
|
||||
}
|
||||
|
||||
@@ -260,6 +260,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
remaining_percent: 68,
|
||||
resets_at: secondary_reset_timestamp,
|
||||
}),
|
||||
spend_control_reached: Some(false),
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
rate_limit_reached_type: Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached),
|
||||
},
|
||||
@@ -287,6 +288,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
remaining_percent: 68,
|
||||
resets_at: secondary_reset_timestamp,
|
||||
}),
|
||||
spend_control_reached: Some(false),
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
rate_limit_reached_type: Some(
|
||||
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
|
||||
@@ -306,6 +308,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
},
|
||||
|
||||
@@ -490,17 +490,12 @@ 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,
|
||||
payload.spend_control.flatten().map(|details| *details),
|
||||
plan_type,
|
||||
rate_limit_reached_type,
|
||||
)];
|
||||
@@ -511,7 +506,7 @@ impl Client {
|
||||
Some(details.limit_name),
|
||||
details.rate_limit.flatten().map(|rate_limit| *rate_limit),
|
||||
/*credits*/ None,
|
||||
/*individual_limit*/ None,
|
||||
/*spend_control*/ None,
|
||||
plan_type,
|
||||
/*rate_limit_reached_type*/ None,
|
||||
)
|
||||
@@ -525,7 +520,7 @@ impl Client {
|
||||
limit_name: Option<String>,
|
||||
rate_limit: Option<crate::types::RateLimitStatusDetails>,
|
||||
credits: Option<crate::types::CreditStatusDetails>,
|
||||
individual_limit: Option<SpendControlLimitSnapshot>,
|
||||
spend_control: Option<codex_backend_openapi_models::models::SpendControlStatusDetails>,
|
||||
plan_type: Option<AccountPlanType>,
|
||||
rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
) -> RateLimitSnapshot {
|
||||
@@ -536,6 +531,10 @@ impl Client {
|
||||
),
|
||||
None => (None, None),
|
||||
};
|
||||
let spend_control_reached = spend_control.as_ref().map(|details| details.reached);
|
||||
let individual_limit = spend_control
|
||||
.and_then(|details| details.individual_limit.flatten())
|
||||
.map(|details| Self::map_individual_limit(*details));
|
||||
RateLimitSnapshot {
|
||||
limit_id,
|
||||
limit_name,
|
||||
@@ -543,6 +542,7 @@ impl Client {
|
||||
secondary,
|
||||
credits: Self::map_credits(credits),
|
||||
individual_limit,
|
||||
spend_control_reached,
|
||||
plan_type,
|
||||
rate_limit_reached_type,
|
||||
}
|
||||
@@ -767,6 +767,7 @@ mod tests {
|
||||
})
|
||||
);
|
||||
assert_eq!(snapshots[0].plan_type, Some(AccountPlanType::Pro));
|
||||
assert_eq!(snapshots[0].spend_control_reached, Some(false));
|
||||
assert_eq!(
|
||||
snapshots[0].rate_limit_reached_type,
|
||||
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted)
|
||||
@@ -789,6 +790,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(snapshots[1].credits, None);
|
||||
assert_eq!(snapshots[1].individual_limit, None);
|
||||
assert_eq!(snapshots[1].spend_control_reached, None);
|
||||
assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro));
|
||||
assert_eq!(snapshots[1].rate_limit_reached_type, None);
|
||||
}
|
||||
@@ -817,6 +819,29 @@ mod tests {
|
||||
assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_payload_maps_spend_control_reached_without_individual_limit() {
|
||||
let payload = RateLimitStatusPayload {
|
||||
plan_type: crate::types::PlanType::EnterpriseCbpUsageBased,
|
||||
rate_limit: None,
|
||||
additional_rate_limits: None,
|
||||
credits: None,
|
||||
spend_control: Some(Some(Box::new(
|
||||
codex_backend_openapi_models::models::SpendControlStatusDetails {
|
||||
reached: true,
|
||||
individual_limit: None,
|
||||
},
|
||||
))),
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
|
||||
let snapshots = Client::rate_limit_snapshots_from_payload(payload);
|
||||
|
||||
assert_eq!(snapshots.len(), 1);
|
||||
assert_eq!(snapshots[0].spend_control_reached, Some(true));
|
||||
assert_eq!(snapshots[0].individual_limit, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_snapshot_selection_matches_get_rate_limits_behavior() {
|
||||
let snapshots = [
|
||||
@@ -831,6 +856,7 @@ mod tests {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
},
|
||||
@@ -845,6 +871,7 @@ mod tests {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
},
|
||||
|
||||
@@ -86,12 +86,18 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
|
||||
if let Ok(err) = serde_json::from_str::<UsageErrorResponse>(&body_text) {
|
||||
if err.error.error_type.as_deref() == Some("usage_limit_reached") {
|
||||
let limit_id = extract_header(headers.as_ref(), ACTIVE_LIMIT_HEADER);
|
||||
let rate_limits = headers.as_ref().and_then(|map| {
|
||||
parse_rate_limit_for_limit(map, limit_id.as_deref())
|
||||
});
|
||||
let promo_message = headers.as_ref().and_then(parse_promo_message);
|
||||
let rate_limit_reached_type =
|
||||
headers.as_ref().and_then(parse_rate_limit_reached_type);
|
||||
let rate_limits = headers
|
||||
.as_ref()
|
||||
.and_then(|map| {
|
||||
parse_rate_limit_for_limit(map, limit_id.as_deref())
|
||||
})
|
||||
.map(|mut snapshot| {
|
||||
snapshot.rate_limit_reached_type = rate_limit_reached_type;
|
||||
snapshot
|
||||
});
|
||||
let resets_at = err
|
||||
.error
|
||||
.resets_at
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
use codex_protocol::protocol::RateLimitReachedType;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
@@ -222,6 +223,70 @@ fn map_api_error_does_not_fallback_limit_name_to_limit_id() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_copies_rate_limit_reached_type_to_usage_limit_snapshot() {
|
||||
for (active_limit, expected_limit_id) in [(None, "codex"), (Some("codex_other"), "codex_other")]
|
||||
{
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(active_limit) = active_limit {
|
||||
headers.insert(
|
||||
ACTIVE_LIMIT_HEADER,
|
||||
http::HeaderValue::from_static(active_limit),
|
||||
);
|
||||
}
|
||||
for (name, value) in [
|
||||
("x-codex-credits-has-credits", "true"),
|
||||
("x-codex-credits-unlimited", "false"),
|
||||
("x-codex-credits-balance", ""),
|
||||
(
|
||||
"x-codex-rate-limit-reached-type",
|
||||
"workspace_member_usage_limit_reached",
|
||||
),
|
||||
] {
|
||||
headers.insert(name, http::HeaderValue::from_static(value));
|
||||
}
|
||||
let body = serde_json::json!({
|
||||
"error": {
|
||||
"type": "usage_limit_reached",
|
||||
"plan_type": "pro",
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
||||
status: http::StatusCode::TOO_MANY_REQUESTS,
|
||||
url: Some("http://example.com/v1/responses".to_string()),
|
||||
headers: Some(headers),
|
||||
body: Some(body),
|
||||
}));
|
||||
|
||||
let CodexErr::UsageLimitReached(usage_limit) = err else {
|
||||
panic!("expected CodexErr::UsageLimitReached, got {err:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
usage_limit.rate_limit_reached_type,
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
|
||||
);
|
||||
let snapshot = usage_limit
|
||||
.rate_limits
|
||||
.as_ref()
|
||||
.expect("usage limit snapshot");
|
||||
assert_eq!(snapshot.limit_id.as_deref(), Some(expected_limit_id));
|
||||
assert_eq!(
|
||||
snapshot.rate_limit_reached_type,
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.credits.as_ref().map(|credits| (
|
||||
credits.has_credits,
|
||||
credits.unlimited,
|
||||
credits.balance.as_deref()
|
||||
)),
|
||||
Some((true, false, None))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_ignores_unparseable_rate_limit_reached_type_headers() {
|
||||
let values = [
|
||||
|
||||
@@ -94,6 +94,7 @@ pub fn parse_rate_limit_for_limit(
|
||||
secondary,
|
||||
credits,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
@@ -159,6 +160,7 @@ pub fn parse_rate_limit_event(payload: &str) -> Option<RateLimitSnapshot> {
|
||||
secondary,
|
||||
credits,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: event.plan_type,
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
|
||||
@@ -3795,6 +3795,7 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
balance: Some("10.00".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -3815,6 +3816,7 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -3829,6 +3831,7 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
secondary: update.secondary,
|
||||
credits: initial.credits,
|
||||
individual_limit: initial.individual_limit,
|
||||
spend_control_reached: initial.spend_control_reached,
|
||||
plan_type: initial.plan_type,
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
@@ -3905,6 +3908,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
balance: Some("15.00".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -3921,6 +3925,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: Some(codex_protocol::account::PlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -3935,6 +3940,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
secondary: update.secondary,
|
||||
credits: initial.credits,
|
||||
individual_limit: initial.individual_limit,
|
||||
spend_control_reached: initial.spend_control_reached,
|
||||
plan_type: update.plan_type,
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
|
||||
@@ -327,6 +327,9 @@ fn merge_rate_limit_fields(
|
||||
if snapshot.individual_limit.is_none() {
|
||||
snapshot.individual_limit = previous.and_then(|prior| prior.individual_limit.clone());
|
||||
}
|
||||
if snapshot.spend_control_reached.is_none() {
|
||||
snapshot.spend_control_reached = previous.and_then(|prior| prior.spend_control_reached);
|
||||
}
|
||||
if snapshot.plan_type.is_none() {
|
||||
snapshot.plan_type = previous.and_then(|prior| prior.plan_type);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
@@ -96,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,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
@@ -110,6 +112,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
@@ -148,6 +151,7 @@ async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() {
|
||||
remaining_percent: 68,
|
||||
resets_at: 300,
|
||||
}),
|
||||
spend_control_reached: Some(true),
|
||||
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
@@ -163,6 +167,7 @@ async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
@@ -189,8 +194,29 @@ async fn set_rate_limits_carries_account_metadata_from_codex_to_codex_other() {
|
||||
remaining_percent: 68,
|
||||
resets_at: 300,
|
||||
}),
|
||||
spend_control_reached: Some(true),
|
||||
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
);
|
||||
|
||||
state.set_rate_limits(RateLimitSnapshot {
|
||||
limit_id: Some("codex_other".to_string()),
|
||||
limit_name: None,
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: Some(false),
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.latest_rate_limits
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.spend_control_reached),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3324,6 +3324,7 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
},
|
||||
"credits": null,
|
||||
"individual_limit": null,
|
||||
"spend_control_reached": null,
|
||||
"plan_type": null,
|
||||
"rate_limit_reached_type": null
|
||||
}
|
||||
@@ -3365,6 +3366,13 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
|
||||
.insert_header("x-codex-primary-over-secondary-limit-percent", "95.0")
|
||||
.insert_header("x-codex-primary-window-minutes", "15")
|
||||
.insert_header("x-codex-secondary-window-minutes", "60")
|
||||
.insert_header("x-codex-credits-has-credits", "true")
|
||||
.insert_header("x-codex-credits-unlimited", "false")
|
||||
.insert_header("x-codex-credits-balance", "")
|
||||
.insert_header(
|
||||
"x-codex-rate-limit-reached-type",
|
||||
"workspace_member_usage_limit_reached",
|
||||
)
|
||||
.set_body_json(json!({
|
||||
"error": {
|
||||
"type": "usage_limit_reached",
|
||||
@@ -3398,10 +3406,15 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
|
||||
"window_minutes": 60,
|
||||
"resets_at": null
|
||||
},
|
||||
"credits": null,
|
||||
"credits": {
|
||||
"has_credits": true,
|
||||
"unlimited": false,
|
||||
"balance": null
|
||||
},
|
||||
"individual_limit": null,
|
||||
"spend_control_reached": null,
|
||||
"plan_type": null,
|
||||
"rate_limit_reached_type": null
|
||||
"rate_limit_reached_type": "workspace_member_usage_limit_reached"
|
||||
});
|
||||
|
||||
let submission_id = codex
|
||||
@@ -3437,7 +3450,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
|
||||
unreachable!();
|
||||
};
|
||||
assert!(
|
||||
error_event.message.to_lowercase().contains("usage limit"),
|
||||
error_event.message.contains("spend cap set by the owner"),
|
||||
"unexpected error message for submission {submission_id}: {}",
|
||||
error_event.message
|
||||
);
|
||||
|
||||
@@ -1483,7 +1483,11 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() {
|
||||
"x-codex-secondary-used-percent": "87.5",
|
||||
"x-codex-primary-over-secondary-limit-percent": "95.0",
|
||||
"x-codex-primary-window-minutes": "15",
|
||||
"x-codex-secondary-window-minutes": "60"
|
||||
"x-codex-secondary-window-minutes": "60",
|
||||
"x-codex-credits-has-credits": "true",
|
||||
"x-codex-credits-unlimited": "false",
|
||||
"x-codex-credits-balance": "",
|
||||
"x-codex-rate-limit-reached-type": "workspace_member_usage_limit_reached"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1543,10 +1547,15 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() {
|
||||
"window_minutes": 60,
|
||||
"resets_at": null
|
||||
},
|
||||
"credits": null,
|
||||
"credits": {
|
||||
"has_credits": true,
|
||||
"unlimited": false,
|
||||
"balance": null
|
||||
},
|
||||
"individual_limit": null,
|
||||
"spend_control_reached": null,
|
||||
"plan_type": null,
|
||||
"rate_limit_reached_type": null
|
||||
"rate_limit_reached_type": "workspace_member_usage_limit_reached"
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -1556,7 +1565,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() {
|
||||
unreachable!();
|
||||
};
|
||||
assert!(
|
||||
error_event.message.to_lowercase().contains("usage limit"),
|
||||
error_event.message.contains("spend cap set by the owner"),
|
||||
"unexpected error message for submission {submission_id}: {}",
|
||||
error_event.message
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ fn snapshot(
|
||||
secondary: secondary_used_percent.map(window),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ fn rate_limit_snapshot() -> RateLimitSnapshot {
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}
|
||||
|
||||
@@ -2124,6 +2124,8 @@ pub struct RateLimitSnapshot {
|
||||
pub secondary: Option<RateLimitWindow>,
|
||||
pub credits: Option<CreditsSnapshot>,
|
||||
pub individual_limit: Option<SpendControlLimitSnapshot>,
|
||||
/// Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.
|
||||
pub spend_control_reached: Option<bool>,
|
||||
pub plan_type: Option<crate::account::PlanType>,
|
||||
pub rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
}
|
||||
|
||||
@@ -577,6 +577,8 @@ pub(crate) struct App {
|
||||
pending_primary_events: VecDeque<ThreadBufferedEvent>,
|
||||
pending_app_server_requests: PendingAppServerRequests,
|
||||
pending_startup_thread_start: bool,
|
||||
/// Invalidates in-flight full rate-limit reads when a newer rolling hard stop arrives.
|
||||
rate_limit_hard_stop_generation: u64,
|
||||
// Serialize plugin enablement writes per plugin so stale completions cannot
|
||||
// overwrite a newer toggle, even if the plugin is toggled from different
|
||||
// cwd contexts.
|
||||
@@ -1066,6 +1068,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
pending_startup_thread_start,
|
||||
rate_limit_hard_stop_generation: 0,
|
||||
pending_plugin_enabled_writes: HashMap::new(),
|
||||
pending_hook_enabled_writes: HashMap::new(),
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::app_server_session::AppServerSession;
|
||||
use crate::app_server_session::status_account_display_from_auth_mode;
|
||||
use codex_app_server_client::AppServerEvent;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::RateLimitReachedType;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
|
||||
@@ -76,6 +77,19 @@ impl App {
|
||||
self.refresh_mcp_startup_expected_servers_from_config();
|
||||
}
|
||||
ServerNotification::AccountRateLimitsUpdated(notification) => {
|
||||
if matches!(
|
||||
notification.rate_limits.rate_limit_reached_type,
|
||||
Some(
|
||||
RateLimitReachedType::WorkspaceOwnerCreditsDepleted
|
||||
| RateLimitReachedType::WorkspaceMemberCreditsDepleted
|
||||
| RateLimitReachedType::WorkspaceOwnerUsageLimitReached
|
||||
| RateLimitReachedType::WorkspaceMemberUsageLimitReached
|
||||
)
|
||||
) || notification.rate_limits.spend_control_reached == Some(true)
|
||||
{
|
||||
self.rate_limit_hard_stop_generation =
|
||||
self.rate_limit_hard_stop_generation.wrapping_add(1);
|
||||
}
|
||||
self.chat_widget
|
||||
.on_rolling_rate_limit_snapshot(notification.rate_limits.clone());
|
||||
return;
|
||||
|
||||
@@ -80,6 +80,7 @@ impl App {
|
||||
) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
let hard_stop_generation = self.rate_limit_hard_stop_generation;
|
||||
tokio::spawn(async move {
|
||||
let request = fetch_account_rate_limits(request_handle);
|
||||
let result = match origin {
|
||||
@@ -96,7 +97,11 @@ impl App {
|
||||
request.await.map_err(|err| err.to_string())
|
||||
}
|
||||
};
|
||||
app_event_tx.send(AppEvent::RateLimitsLoaded { origin, result });
|
||||
app_event_tx.send(AppEvent::RateLimitsLoaded {
|
||||
origin,
|
||||
hard_stop_generation,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -832,10 +832,19 @@ impl App {
|
||||
self.chat_widget
|
||||
.finish_add_credits_nudge_email_request(result);
|
||||
}
|
||||
AppEvent::RateLimitsLoaded { origin, result } => match result {
|
||||
AppEvent::RateLimitsLoaded {
|
||||
origin,
|
||||
hard_stop_generation,
|
||||
result,
|
||||
} => match result {
|
||||
Ok(response) => {
|
||||
let rate_limit_reset_credits = response.rate_limit_reset_credits.clone();
|
||||
let snapshots = app_server_rate_limit_snapshots(response);
|
||||
let snapshots = if hard_stop_generation == self.rate_limit_hard_stop_generation
|
||||
{
|
||||
app_server_rate_limit_snapshots(response)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
match origin {
|
||||
RateLimitRefreshOrigin::StartupPrefetch {
|
||||
reset_hint_request_id,
|
||||
|
||||
@@ -64,6 +64,7 @@ pub(super) async fn make_test_app() -> App {
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
pending_startup_thread_start: false,
|
||||
rate_limit_hard_stop_generation: 0,
|
||||
pending_plugin_enabled_writes: HashMap::new(),
|
||||
pending_hook_enabled_writes: HashMap::new(),
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
mod advanced_reasoning_tests;
|
||||
mod model_catalog;
|
||||
mod plugin_catalog;
|
||||
mod rate_limits;
|
||||
mod session_summary;
|
||||
mod startup;
|
||||
|
||||
@@ -4104,6 +4105,7 @@ async fn make_test_app() -> App {
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
pending_startup_thread_start: false,
|
||||
rate_limit_hard_stop_generation: 0,
|
||||
pending_plugin_enabled_writes: HashMap::new(),
|
||||
pending_hook_enabled_writes: HashMap::new(),
|
||||
}
|
||||
@@ -4169,6 +4171,7 @@ async fn make_test_app_with_channels() -> (
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||||
pending_startup_thread_start: false,
|
||||
rate_limit_hard_stop_generation: 0,
|
||||
pending_plugin_enabled_writes: HashMap::new(),
|
||||
pending_hook_enabled_writes: HashMap::new(),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
use super::*;
|
||||
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
|
||||
use codex_app_server_protocol::CodexErrorInfo;
|
||||
use codex_app_server_protocol::CreditsSnapshot;
|
||||
use codex_app_server_protocol::ErrorNotification;
|
||||
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
||||
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 crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn rate_limit_snapshot(
|
||||
used_percent: i32,
|
||||
rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
spend_control_reached: Option<bool>,
|
||||
) -> RateLimitSnapshot {
|
||||
RateLimitSnapshot {
|
||||
limit_id: Some("codex".to_string()),
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent,
|
||||
window_duration_mins: Some(300),
|
||||
resets_at: None,
|
||||
}),
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: None,
|
||||
}),
|
||||
individual_limit: None,
|
||||
spend_control_reached,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type,
|
||||
}
|
||||
}
|
||||
|
||||
fn account_rate_limits_response(snapshot: RateLimitSnapshot) -> GetAccountRateLimitsResponse {
|
||||
GetAccountRateLimitsResponse {
|
||||
rate_limits: snapshot,
|
||||
rate_limits_by_limit_id: None,
|
||||
rate_limit_reset_credits: Some(RateLimitResetCreditsSummary {
|
||||
available_count: 0,
|
||||
credits: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn deliver_rolling_rate_limit_snapshot(
|
||||
app: &mut App,
|
||||
app_server: &AppServerSession,
|
||||
snapshot: RateLimitSnapshot,
|
||||
) {
|
||||
app.handle_app_server_event(
|
||||
app_server,
|
||||
codex_app_server_client::AppServerEvent::ServerNotification(
|
||||
ServerNotification::AccountRateLimitsUpdated(AccountRateLimitsUpdatedNotification {
|
||||
rate_limits: snapshot,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn render_status_output(
|
||||
app: &mut App,
|
||||
app_event_rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
) -> String {
|
||||
while app_event_rx.try_recv().is_ok() {}
|
||||
app.chat_widget.add_status_output(
|
||||
/*refreshing_rate_limits*/ false, /*request_id*/ None,
|
||||
);
|
||||
match app_event_rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell
|
||||
.display_lines(/*width*/ 120)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
other => panic!("expected status output, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn deliver_usage_limit_error(app: &mut App) {
|
||||
app.chat_widget.handle_server_notification(
|
||||
ServerNotification::Error(ErrorNotification {
|
||||
error: AppServerTurnError {
|
||||
message: "Usage limit reached.".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded),
|
||||
additional_details: None,
|
||||
},
|
||||
will_retry: false,
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rolling_workspace_hard_stops_invalidate_older_rate_limit_reads() -> Result<()> {
|
||||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
|
||||
.await
|
||||
.expect("embedded app server");
|
||||
|
||||
let cases = [
|
||||
(None, None, false),
|
||||
(Some(RateLimitReachedType::RateLimitReached), None, false),
|
||||
(None, Some(false), false),
|
||||
(None, Some(true), true),
|
||||
(
|
||||
Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted),
|
||||
None,
|
||||
true,
|
||||
),
|
||||
(
|
||||
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted),
|
||||
None,
|
||||
true,
|
||||
),
|
||||
(
|
||||
Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached),
|
||||
None,
|
||||
true,
|
||||
),
|
||||
(
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached),
|
||||
None,
|
||||
true,
|
||||
),
|
||||
];
|
||||
let mut expected_generation = 0;
|
||||
for (reached_type, spend_control_reached, invalidates) in cases {
|
||||
deliver_rolling_rate_limit_snapshot(
|
||||
&mut app,
|
||||
&app_server,
|
||||
rate_limit_snapshot(
|
||||
/*used_percent*/ 95,
|
||||
reached_type,
|
||||
spend_control_reached,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
if invalidates {
|
||||
expected_generation += 1;
|
||||
}
|
||||
assert_eq!(
|
||||
app.rate_limit_hard_stop_generation, expected_generation,
|
||||
"reached_type={reached_type:?}, spend_control_reached={spend_control_reached:?}"
|
||||
);
|
||||
}
|
||||
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_rate_limit_reads_preserve_newer_workspace_hard_stop_for_every_origin() -> Result<()>
|
||||
{
|
||||
for origin_name in [
|
||||
"startup",
|
||||
"status",
|
||||
"usage",
|
||||
"reset-picker",
|
||||
"reset-consume",
|
||||
] {
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
set_chatgpt_auth(&mut app.chat_widget);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||||
app.chat_widget.config_ref(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
let origin = match origin_name {
|
||||
"startup" => RateLimitRefreshOrigin::StartupPrefetch {
|
||||
reset_hint_request_id: app.chat_widget.start_rate_limit_reset_startup_check(),
|
||||
},
|
||||
"status" => {
|
||||
let request_id = 7;
|
||||
app.chat_widget
|
||||
.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id));
|
||||
RateLimitRefreshOrigin::StatusCommand { request_id }
|
||||
}
|
||||
"usage" => {
|
||||
let startup_request_id = app.chat_widget.start_rate_limit_reset_startup_check();
|
||||
app.chat_widget.finish_rate_limit_reset_hint_refresh(
|
||||
startup_request_id,
|
||||
Vec::new(),
|
||||
Ok(RateLimitResetCreditsSummary {
|
||||
available_count: 0,
|
||||
credits: None,
|
||||
}),
|
||||
);
|
||||
app.chat_widget
|
||||
.set_composer_text("/usage".to_string(), Vec::new(), Vec::new());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
loop {
|
||||
match app_event_rx.try_recv() {
|
||||
Ok(AppEvent::RefreshRateLimits { origin }) => break origin,
|
||||
Ok(_) => {}
|
||||
other => panic!("expected usage refresh request, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
"reset-picker" => RateLimitRefreshOrigin::ResetPicker {
|
||||
request_id: app.chat_widget.show_rate_limit_reset_loading_popup(),
|
||||
},
|
||||
"reset-consume" => RateLimitRefreshOrigin::ResetConsume {
|
||||
request_id: app.chat_widget.show_rate_limit_reset_consuming_popup(),
|
||||
},
|
||||
_ => unreachable!("unknown refresh origin"),
|
||||
};
|
||||
let read_generation = app.rate_limit_hard_stop_generation;
|
||||
let mut rolling_snapshot = rate_limit_snapshot(
|
||||
/*used_percent*/ 95,
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached),
|
||||
Some(true),
|
||||
);
|
||||
if origin_name == "reset-picker" {
|
||||
rolling_snapshot.limit_id = Some("codex_other".to_string());
|
||||
}
|
||||
deliver_rolling_rate_limit_snapshot(&mut app, &app_server, rolling_snapshot).await;
|
||||
assert_ne!(read_generation, app.rate_limit_hard_stop_generation);
|
||||
|
||||
let control = Box::pin(app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::RateLimitsLoaded {
|
||||
origin,
|
||||
hard_stop_generation: read_generation,
|
||||
result: Ok(account_rate_limits_response(rate_limit_snapshot(
|
||||
/*used_percent*/ 0,
|
||||
/*rate_limit_reached_type*/ None,
|
||||
Some(false),
|
||||
))),
|
||||
},
|
||||
))
|
||||
.await?;
|
||||
assert!(matches!(control, AppRunControl::Continue));
|
||||
|
||||
let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100);
|
||||
match origin_name {
|
||||
"usage" => assert!(popup.contains("No usage limit resets available.")),
|
||||
"reset-picker" => {
|
||||
assert!(popup.contains("You don't have any usage limit resets available."));
|
||||
}
|
||||
"reset-consume" => {
|
||||
assert!(popup.contains("Usage reset. You have 0 usage limit resets left."));
|
||||
}
|
||||
"startup" | "status" => {}
|
||||
_ => unreachable!("unknown refresh origin"),
|
||||
}
|
||||
|
||||
let status = render_status_output(&mut app, &mut app_event_rx);
|
||||
assert!(
|
||||
status.contains("5% left"),
|
||||
"expected {origin_name} to preserve rolling limits, got: {status}"
|
||||
);
|
||||
deliver_usage_limit_error(&mut app);
|
||||
let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100);
|
||||
assert!(
|
||||
popup.contains("Request a limit increase from your owner"),
|
||||
"expected {origin_name} to preserve workspace error routing, got: {popup}"
|
||||
);
|
||||
|
||||
app_server.shutdown().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_rate_limit_read_does_not_dismiss_visible_workspace_advisory() -> Result<()> {
|
||||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
set_chatgpt_auth(&mut app.chat_widget);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||||
app.chat_widget.config_ref(),
|
||||
))
|
||||
.await?;
|
||||
let request_id = 7;
|
||||
app.chat_widget
|
||||
.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id));
|
||||
let read_generation = app.rate_limit_hard_stop_generation;
|
||||
|
||||
deliver_rolling_rate_limit_snapshot(
|
||||
&mut app,
|
||||
&app_server,
|
||||
rate_limit_snapshot(
|
||||
/*used_percent*/ 95,
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached),
|
||||
Some(true),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
app.chat_widget.handle_server_notification(
|
||||
turn_completed_notification(ThreadId::new(), "turn-1", TurnStatus::Completed),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
assert!(
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 100).contains("Approaching rate limits")
|
||||
);
|
||||
|
||||
Box::pin(app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::RateLimitsLoaded {
|
||||
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
|
||||
hard_stop_generation: read_generation,
|
||||
result: Ok(account_rate_limits_response(rate_limit_snapshot(
|
||||
/*used_percent*/ 0,
|
||||
/*rate_limit_reached_type*/ None,
|
||||
Some(false),
|
||||
))),
|
||||
},
|
||||
))
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 100).contains("Approaching rate limits")
|
||||
);
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_hard_stop_rate_limit_read_clears_recovered_workspace_limit() -> Result<()> {
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
set_chatgpt_auth(&mut app.chat_widget);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||||
app.chat_widget.config_ref(),
|
||||
))
|
||||
.await?;
|
||||
deliver_rolling_rate_limit_snapshot(
|
||||
&mut app,
|
||||
&app_server,
|
||||
rate_limit_snapshot(
|
||||
/*used_percent*/ 95,
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached),
|
||||
Some(true),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let read_generation = app.rate_limit_hard_stop_generation;
|
||||
let request_id = 7;
|
||||
app.chat_widget
|
||||
.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id));
|
||||
|
||||
let control = Box::pin(app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::RateLimitsLoaded {
|
||||
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
|
||||
hard_stop_generation: read_generation,
|
||||
result: Ok(account_rate_limits_response(rate_limit_snapshot(
|
||||
/*used_percent*/ 0,
|
||||
/*rate_limit_reached_type*/ None,
|
||||
Some(false),
|
||||
))),
|
||||
},
|
||||
))
|
||||
.await?;
|
||||
assert!(matches!(control, AppRunControl::Continue));
|
||||
|
||||
let status = render_status_output(&mut app, &mut app_event_rx);
|
||||
assert!(
|
||||
status.contains("100% left"),
|
||||
"expected recovered limits, got: {status}"
|
||||
);
|
||||
deliver_usage_limit_error(&mut app);
|
||||
let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100);
|
||||
assert!(
|
||||
!popup.contains("Request a limit increase from your owner"),
|
||||
"expected recovered state to clear workspace error routing, got: {popup}"
|
||||
);
|
||||
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -317,6 +317,7 @@ pub(crate) enum AppEvent {
|
||||
/// Result of refreshing rate limits.
|
||||
RateLimitsLoaded {
|
||||
origin: RateLimitRefreshOrigin,
|
||||
hard_stop_generation: u64,
|
||||
result: Result<GetAccountRateLimitsResponse, String>,
|
||||
},
|
||||
|
||||
|
||||
@@ -1846,6 +1846,7 @@ mod tests {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}
|
||||
|
||||
@@ -565,6 +565,7 @@ pub(crate) struct ChatWidget {
|
||||
next_rate_limit_reset_request_id: u64,
|
||||
plan_type: Option<PlanType>,
|
||||
codex_rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
codex_spend_control_reached: Option<bool>,
|
||||
rate_limit_warnings: RateLimitWarningState,
|
||||
warning_display_state: WarningDisplayState,
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState,
|
||||
|
||||
@@ -137,6 +137,7 @@ impl ChatWidget {
|
||||
next_rate_limit_reset_request_id: 0,
|
||||
plan_type: initial_plan_type,
|
||||
codex_rate_limit_reached_type: None,
|
||||
codex_spend_control_reached: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
warning_display_state: WarningDisplayState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
|
||||
@@ -157,6 +157,10 @@ enum RateLimitSnapshotSource {
|
||||
RollingUpdate,
|
||||
}
|
||||
|
||||
fn has_usable_workspace_credits(credits: &CreditsSnapshot) -> bool {
|
||||
credits.unlimited || credits.has_credits
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
|
||||
self.on_rate_limit_snapshot_from(snapshot, RateLimitSnapshotSource::AccountUsage);
|
||||
@@ -181,7 +185,9 @@ impl ChatWidget {
|
||||
.limit_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| limit_id.clone());
|
||||
if snapshot.credits.is_none() {
|
||||
if matches!(source, RateLimitSnapshotSource::RollingUpdate)
|
||||
&& snapshot.credits.is_none()
|
||||
{
|
||||
snapshot.credits = self
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get(&limit_id)
|
||||
@@ -206,21 +212,43 @@ impl ChatWidget {
|
||||
|
||||
let is_codex_limit = limit_id.eq_ignore_ascii_case("codex");
|
||||
if is_codex_limit
|
||||
&& let Some(rate_limit_reached_type) = snapshot.rate_limit_reached_type
|
||||
&& (matches!(source, RateLimitSnapshotSource::AccountUsage)
|
||||
|| snapshot.spend_control_reached.is_some())
|
||||
{
|
||||
self.codex_rate_limit_reached_type = Some(rate_limit_reached_type);
|
||||
self.codex_spend_control_reached = snapshot.spend_control_reached;
|
||||
}
|
||||
if (is_codex_limit && matches!(source, RateLimitSnapshotSource::AccountUsage))
|
||||
|| snapshot.rate_limit_reached_type.is_some()
|
||||
{
|
||||
self.codex_rate_limit_reached_type = snapshot.rate_limit_reached_type;
|
||||
}
|
||||
let workspace_limit_reached = self.codex_spend_control_reached == Some(true)
|
||||
|| matches!(
|
||||
self.codex_rate_limit_reached_type,
|
||||
Some(
|
||||
RateLimitReachedType::WorkspaceOwnerCreditsDepleted
|
||||
| RateLimitReachedType::WorkspaceMemberCreditsDepleted
|
||||
| RateLimitReachedType::WorkspaceOwnerUsageLimitReached
|
||||
| RateLimitReachedType::WorkspaceMemberUsageLimitReached
|
||||
)
|
||||
);
|
||||
let has_workspace_credits = !workspace_limit_reached
|
||||
&& snapshot
|
||||
.credits
|
||||
.as_ref()
|
||||
.is_some_and(has_usable_workspace_credits);
|
||||
if is_codex_limit && has_workspace_credits {
|
||||
match self.rate_limit_switch_prompt {
|
||||
RateLimitSwitchPromptState::Pending => {
|
||||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
||||
}
|
||||
RateLimitSwitchPromptState::Shown => {
|
||||
self.bottom_pane
|
||||
.dismiss_view_by_id(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID);
|
||||
}
|
||||
RateLimitSwitchPromptState::Idle => {}
|
||||
}
|
||||
}
|
||||
|
||||
let has_workspace_credits = snapshot.credits.as_ref().is_some_and(|credits| {
|
||||
credits.has_credits
|
||||
&& (credits.unlimited
|
||||
|| credits.balance.as_deref().is_some_and(|balance| {
|
||||
balance
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.is_ok_and(|balance| balance > 0.0)
|
||||
}))
|
||||
});
|
||||
let should_warn_about_rate_limit_usage = is_codex_limit && !has_workspace_credits;
|
||||
let warnings = if should_warn_about_rate_limit_usage {
|
||||
self.rate_limit_warnings.take_warnings(
|
||||
@@ -286,6 +314,7 @@ impl ChatWidget {
|
||||
} else {
|
||||
self.rate_limit_snapshots_by_limit_id.clear();
|
||||
self.codex_rate_limit_reached_type = None;
|
||||
self.codex_spend_control_reached = None;
|
||||
}
|
||||
self.refresh_status_line();
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ impl ChatWidget {
|
||||
self.clear_pending_token_activity_refreshes();
|
||||
self.clear_pending_rate_limit_reset_requests();
|
||||
self.codex_rate_limit_reached_type = None;
|
||||
self.codex_spend_control_reached = None;
|
||||
self.rate_limit_warnings = RateLimitWarningState::default();
|
||||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
||||
self.bottom_pane
|
||||
|
||||
@@ -113,6 +113,7 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}
|
||||
|
||||
@@ -593,6 +593,7 @@ async fn status_line_uses_secondary_fallback_for_unsupported_window() {
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
spend_control_reached: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
|
||||
@@ -622,6 +623,7 @@ async fn status_line_legacy_limit_items_prefer_matching_windows() {
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
spend_control_reached: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
|
||||
@@ -655,6 +657,7 @@ async fn status_line_shows_secondary_non_weekly_when_primary_is_weekly() {
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
spend_control_reached: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
|
||||
@@ -684,6 +687,7 @@ async fn status_line_five_hour_item_omits_weekly_only_limit() {
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
spend_control_reached: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
|
||||
@@ -713,6 +717,7 @@ async fn status_line_single_monthly_primary_omits_weekly_limit_item() {
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
spend_control_reached: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
|
||||
@@ -742,6 +747,7 @@ async fn status_line_secondary_only_non_weekly_limit_omits_primary_limit_item()
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
spend_control_reached: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
|
||||
@@ -771,6 +777,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
spend_control_reached: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
let initial_balance = chat
|
||||
@@ -780,7 +787,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
||||
.and_then(|credits| credits.balance.as_deref());
|
||||
assert_eq!(initial_balance, Some("17.5"));
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
chat.on_rolling_rate_limit_snapshot(RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
@@ -792,8 +799,9 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
spend_control_reached: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
});
|
||||
|
||||
let display = chat
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
@@ -866,6 +874,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: Some(PlanType::Plus),
|
||||
spend_control_reached: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
assert_eq!(chat.plan_type, Some(PlanType::Plus));
|
||||
@@ -886,6 +895,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: Some(PlanType::Pro),
|
||||
spend_control_reached: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
assert_eq!(chat.plan_type, Some(PlanType::Pro));
|
||||
@@ -906,6 +916,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
spend_control_reached: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
assert_eq!(chat.plan_type, Some(PlanType::Pro));
|
||||
@@ -931,6 +942,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: Some(PlanType::Pro),
|
||||
spend_control_reached: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
|
||||
@@ -946,6 +958,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: Some(PlanType::Pro),
|
||||
spend_control_reached: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
|
||||
@@ -1000,6 +1013,7 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() {
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
spend_control_reached: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
|
||||
@@ -1010,22 +1024,102 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_usage_warnings_show_when_workspace_credits_zero_balance() {
|
||||
async fn rate_limit_usage_warnings_follow_workspace_credit_flags() {
|
||||
for (credits, should_warn) in [
|
||||
(
|
||||
CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: None,
|
||||
},
|
||||
false,
|
||||
),
|
||||
(
|
||||
CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some(String::new()),
|
||||
},
|
||||
false,
|
||||
),
|
||||
(
|
||||
CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("0".to_string()),
|
||||
},
|
||||
false,
|
||||
),
|
||||
(
|
||||
CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("not-a-number".to_string()),
|
||||
},
|
||||
false,
|
||||
),
|
||||
(
|
||||
CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("25.00".to_string()),
|
||||
},
|
||||
false,
|
||||
),
|
||||
(
|
||||
CreditsSnapshot {
|
||||
has_credits: false,
|
||||
unlimited: true,
|
||||
balance: None,
|
||||
},
|
||||
false,
|
||||
),
|
||||
(
|
||||
CreditsSnapshot {
|
||||
has_credits: false,
|
||||
unlimited: false,
|
||||
balance: Some("25.00".to_string()),
|
||||
},
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.has_chatgpt_account = true;
|
||||
let mut rate_limit_snapshot = snapshot(/*percent*/ 95.0);
|
||||
rate_limit_snapshot.credits = Some(credits);
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(rate_limit_snapshot));
|
||||
|
||||
assert_eq!(!drain_insert_history(&mut rx).is_empty(), should_warn);
|
||||
assert_eq!(
|
||||
matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Pending
|
||||
),
|
||||
should_warn
|
||||
);
|
||||
assert_eq!(chat.rate_limit_warnings.primary_index > 0, should_warn);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_usage_warnings_show_when_authoritative_snapshot_clears_credits() {
|
||||
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.has_chatgpt_account = true;
|
||||
|
||||
let mut rate_limit_snapshot = snapshot(/*percent*/ 95.0);
|
||||
rate_limit_snapshot.credits = Some(CreditsSnapshot {
|
||||
let mut initial_snapshot = snapshot(/*percent*/ 0.0);
|
||||
initial_snapshot.credits = Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("0".to_string()),
|
||||
balance: None,
|
||||
});
|
||||
chat.on_rate_limit_snapshot(Some(initial_snapshot));
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(rate_limit_snapshot));
|
||||
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 95.0)));
|
||||
|
||||
assert!(
|
||||
!drain_insert_history(&mut rx).is_empty(),
|
||||
"zero-balance workspace credits should not suppress proactive usage warnings"
|
||||
"an authoritative snapshot without credits should clear stale credit availability"
|
||||
);
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
@@ -1034,55 +1128,396 @@ async fn rate_limit_usage_warnings_show_when_workspace_credits_zero_balance() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_usage_warnings_skip_when_workspace_credits_are_available() {
|
||||
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
async fn rate_limit_switch_prompt_clears_pending_when_workspace_credits_become_usable() {
|
||||
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.has_chatgpt_account = true;
|
||||
|
||||
let mut rate_limit_snapshot = snapshot(/*percent*/ 95.0);
|
||||
rate_limit_snapshot.credits = Some(CreditsSnapshot {
|
||||
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 95.0)));
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Pending
|
||||
));
|
||||
|
||||
let mut funded_snapshot = snapshot(/*percent*/ 95.0);
|
||||
funded_snapshot.credits = Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("25.00".to_string()),
|
||||
balance: None,
|
||||
});
|
||||
chat.on_rate_limit_snapshot(Some(funded_snapshot));
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(rate_limit_snapshot));
|
||||
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
"workspace credits should suppress proactive usage warnings"
|
||||
);
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Idle
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_switch_prompt_dismisses_shown_when_workspace_credits_become_usable() {
|
||||
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.has_chatgpt_account = true;
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 95.0)));
|
||||
chat.maybe_show_pending_rate_limit_prompt();
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Shown
|
||||
));
|
||||
assert!(!chat.bottom_pane.no_modal_or_popup_active());
|
||||
|
||||
let mut funded_snapshot = snapshot(/*percent*/ 95.0);
|
||||
funded_snapshot.credits = Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: None,
|
||||
});
|
||||
chat.on_rate_limit_snapshot(Some(funded_snapshot));
|
||||
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Shown
|
||||
));
|
||||
assert!(chat.bottom_pane.no_modal_or_popup_active());
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 0.0)));
|
||||
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 95.0)));
|
||||
chat.maybe_show_pending_rate_limit_prompt();
|
||||
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Shown
|
||||
));
|
||||
assert!(chat.bottom_pane.no_modal_or_popup_active());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_usage_warnings_preserve_workspace_limit_for_sparse_snapshots() {
|
||||
for rate_limit_reached_type in [
|
||||
RateLimitReachedType::WorkspaceOwnerCreditsDepleted,
|
||||
RateLimitReachedType::WorkspaceMemberCreditsDepleted,
|
||||
RateLimitReachedType::WorkspaceOwnerUsageLimitReached,
|
||||
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
|
||||
] {
|
||||
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.has_chatgpt_account = true;
|
||||
|
||||
let mut blocked_snapshot = snapshot(/*percent*/ 0.0);
|
||||
blocked_snapshot.credits = Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: None,
|
||||
});
|
||||
blocked_snapshot.rate_limit_reached_type = Some(rate_limit_reached_type);
|
||||
chat.on_rate_limit_snapshot(Some(blocked_snapshot));
|
||||
|
||||
chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 95.0));
|
||||
|
||||
assert!(
|
||||
!drain_insert_history(&mut rx).is_empty(),
|
||||
"an explicit workspace hard stop should keep proactive usage warnings enabled"
|
||||
);
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Pending
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_usage_warnings_keep_workspace_limit_after_rolling_credits() {
|
||||
for rate_limit_reached_type in [
|
||||
RateLimitReachedType::WorkspaceOwnerCreditsDepleted,
|
||||
RateLimitReachedType::WorkspaceMemberCreditsDepleted,
|
||||
RateLimitReachedType::WorkspaceOwnerUsageLimitReached,
|
||||
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
|
||||
] {
|
||||
for credits in [
|
||||
CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: None,
|
||||
},
|
||||
CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some(String::new()),
|
||||
},
|
||||
CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("25.00".to_string()),
|
||||
},
|
||||
CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("0".to_string()),
|
||||
},
|
||||
CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("not-a-number".to_string()),
|
||||
},
|
||||
CreditsSnapshot {
|
||||
has_credits: false,
|
||||
unlimited: true,
|
||||
balance: None,
|
||||
},
|
||||
] {
|
||||
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.has_chatgpt_account = true;
|
||||
|
||||
let mut blocked_snapshot = snapshot(/*percent*/ 0.0);
|
||||
blocked_snapshot.credits = Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: None,
|
||||
});
|
||||
blocked_snapshot.rate_limit_reached_type = Some(rate_limit_reached_type);
|
||||
chat.on_rolling_rate_limit_snapshot(blocked_snapshot);
|
||||
|
||||
let mut rolling_snapshot = snapshot(/*percent*/ 95.0);
|
||||
rolling_snapshot.credits = Some(credits);
|
||||
chat.on_rolling_rate_limit_snapshot(rolling_snapshot);
|
||||
|
||||
assert!(
|
||||
!drain_insert_history(&mut rx).is_empty(),
|
||||
"usable rolling workspace credits must not suppress an existing workspace hard stop"
|
||||
);
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Pending
|
||||
));
|
||||
assert_eq!(
|
||||
chat.codex_rate_limit_reached_type,
|
||||
Some(rate_limit_reached_type)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_usage_warnings_keep_explicit_rolling_workspace_limit() {
|
||||
for rate_limit_reached_type in [
|
||||
RateLimitReachedType::WorkspaceOwnerCreditsDepleted,
|
||||
RateLimitReachedType::WorkspaceMemberCreditsDepleted,
|
||||
RateLimitReachedType::WorkspaceOwnerUsageLimitReached,
|
||||
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
|
||||
] {
|
||||
for spend_control_reached in [None, Some(false)] {
|
||||
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.has_chatgpt_account = true;
|
||||
|
||||
let mut rolling_snapshot = snapshot(/*percent*/ 95.0);
|
||||
rolling_snapshot.credits = Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: None,
|
||||
});
|
||||
rolling_snapshot.rate_limit_reached_type = Some(rate_limit_reached_type);
|
||||
rolling_snapshot.spend_control_reached = spend_control_reached;
|
||||
chat.on_rolling_rate_limit_snapshot(rolling_snapshot);
|
||||
|
||||
assert!(
|
||||
!drain_insert_history(&mut rx).is_empty(),
|
||||
"an explicit rolling workspace hard stop should keep proactive usage warnings enabled"
|
||||
);
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Pending
|
||||
));
|
||||
assert_eq!(
|
||||
chat.codex_rate_limit_reached_type,
|
||||
Some(rate_limit_reached_type)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_usage_warnings_keep_newly_reached_workspace_limit() {
|
||||
for (limit_id, should_warn) in [("codex", true), ("codex_other", false)] {
|
||||
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.has_chatgpt_account = true;
|
||||
|
||||
let mut initial_snapshot = snapshot(/*percent*/ 0.0);
|
||||
initial_snapshot.credits = Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: None,
|
||||
});
|
||||
initial_snapshot.spend_control_reached = Some(false);
|
||||
chat.on_rate_limit_snapshot(Some(initial_snapshot));
|
||||
|
||||
let mut capped_snapshot = snapshot(/*percent*/ 95.0);
|
||||
capped_snapshot.limit_id = Some(limit_id.to_string());
|
||||
capped_snapshot.credits = Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: None,
|
||||
});
|
||||
capped_snapshot.rate_limit_reached_type =
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
|
||||
chat.on_rolling_rate_limit_snapshot(capped_snapshot);
|
||||
|
||||
assert_eq!(!drain_insert_history(&mut rx).is_empty(), should_warn);
|
||||
assert_eq!(
|
||||
matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Pending
|
||||
),
|
||||
should_warn
|
||||
);
|
||||
assert_eq!(
|
||||
chat.codex_rate_limit_reached_type,
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
|
||||
);
|
||||
|
||||
chat.on_rate_limit_error(
|
||||
RateLimitErrorKind::UsageLimit,
|
||||
"Usage limit reached.".to_string(),
|
||||
);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(popup.contains("Request a limit increase from your owner"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_usage_warnings_preserve_and_clear_spend_control_state() {
|
||||
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.has_chatgpt_account = true;
|
||||
|
||||
let mut blocked_snapshot = snapshot(/*percent*/ 0.0);
|
||||
blocked_snapshot.credits = Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: None,
|
||||
});
|
||||
blocked_snapshot.spend_control_reached = Some(true);
|
||||
blocked_snapshot.rate_limit_reached_type =
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
|
||||
chat.on_rate_limit_snapshot(Some(blocked_snapshot));
|
||||
assert_eq!(chat.codex_spend_control_reached, Some(true));
|
||||
|
||||
chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 95.0));
|
||||
assert!(
|
||||
!drain_insert_history(&mut rx).is_empty(),
|
||||
"a sparse rolling snapshot should preserve a reached spend control"
|
||||
);
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Pending
|
||||
));
|
||||
assert_eq!(chat.codex_spend_control_reached, Some(true));
|
||||
|
||||
let mut recovered_snapshot = snapshot(/*percent*/ 95.0);
|
||||
recovered_snapshot.credits = Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: None,
|
||||
});
|
||||
recovered_snapshot.spend_control_reached = Some(false);
|
||||
chat.on_rolling_rate_limit_snapshot(recovered_snapshot);
|
||||
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Pending
|
||||
));
|
||||
assert_eq!(chat.codex_spend_control_reached, Some(false));
|
||||
assert_eq!(
|
||||
chat.rate_limit_warnings.primary_index, 0,
|
||||
"suppressed warnings should not consume warning thresholds"
|
||||
chat.codex_rate_limit_reached_type,
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
|
||||
);
|
||||
|
||||
chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 95.0));
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
"a later sparse rolling snapshot should not clear an existing workspace hard stop"
|
||||
);
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Pending
|
||||
));
|
||||
assert_eq!(chat.codex_spend_control_reached, Some(false));
|
||||
assert_eq!(
|
||||
chat.codex_rate_limit_reached_type,
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_usage_warnings_skip_with_unlimited_workspace_credits() {
|
||||
async fn rolling_credits_preserve_depleted_workspace_error_routing() {
|
||||
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.has_chatgpt_account = true;
|
||||
|
||||
let mut rate_limit_snapshot = snapshot(/*percent*/ 95.0);
|
||||
rate_limit_snapshot.credits = Some(CreditsSnapshot {
|
||||
let mut blocked_snapshot = snapshot(/*percent*/ 0.0);
|
||||
blocked_snapshot.rate_limit_reached_type =
|
||||
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted);
|
||||
chat.on_rate_limit_snapshot(Some(blocked_snapshot));
|
||||
|
||||
let mut rolling_snapshot = snapshot(/*percent*/ 95.0);
|
||||
rolling_snapshot.credits = Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: true,
|
||||
unlimited: false,
|
||||
balance: Some("0".to_string()),
|
||||
});
|
||||
rolling_snapshot.spend_control_reached = Some(false);
|
||||
chat.on_rolling_rate_limit_snapshot(rolling_snapshot);
|
||||
|
||||
assert!(!drain_insert_history(&mut rx).is_empty());
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Pending
|
||||
));
|
||||
assert_eq!(
|
||||
chat.codex_rate_limit_reached_type,
|
||||
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted)
|
||||
);
|
||||
|
||||
chat.on_rate_limit_error(
|
||||
RateLimitErrorKind::Generic,
|
||||
"Usage limit reached.".to_string(),
|
||||
);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
popup.contains("Ask your workspace owner to add more"),
|
||||
"popup: {popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_usage_warnings_clear_workspace_limit_from_authoritative_snapshot() {
|
||||
let (mut chat, mut rx, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.has_chatgpt_account = true;
|
||||
|
||||
let mut blocked_snapshot = snapshot(/*percent*/ 0.0);
|
||||
blocked_snapshot.credits = Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: None,
|
||||
});
|
||||
blocked_snapshot.rate_limit_reached_type =
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
|
||||
chat.on_rate_limit_snapshot(Some(blocked_snapshot));
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(rate_limit_snapshot));
|
||||
let mut recovered_snapshot = snapshot(/*percent*/ 0.0);
|
||||
recovered_snapshot.credits = Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: None,
|
||||
});
|
||||
chat.on_rate_limit_snapshot(Some(recovered_snapshot));
|
||||
chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 95.0));
|
||||
|
||||
assert!(
|
||||
drain_insert_history(&mut rx).is_empty(),
|
||||
"unlimited workspace credits should suppress proactive usage warnings"
|
||||
"an authoritative recovery should prevent sparse updates from restoring a stale limit"
|
||||
);
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Idle
|
||||
));
|
||||
assert_eq!(chat.codex_rate_limit_reached_type, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1114,11 +1549,13 @@ async fn account_update_clears_derived_usage_limit_state_and_prompt() {
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let mut limits = snapshot(/*percent*/ 95.0);
|
||||
limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
|
||||
limits.spend_control_reached = Some(true);
|
||||
chat.on_rate_limit_snapshot(Some(limits));
|
||||
chat.maybe_show_pending_rate_limit_prompt();
|
||||
|
||||
assert!(chat.rate_limit_warnings.primary_index > 0);
|
||||
assert!(chat.codex_rate_limit_reached_type.is_some());
|
||||
assert_eq!(chat.codex_spend_control_reached, Some(true));
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Shown
|
||||
@@ -1133,6 +1570,7 @@ async fn account_update_clears_derived_usage_limit_state_and_prompt() {
|
||||
assert_eq!(chat.rate_limit_warnings.primary_index, 0);
|
||||
assert_eq!(chat.rate_limit_warnings.secondary_index, 0);
|
||||
assert_eq!(chat.codex_rate_limit_reached_type, None);
|
||||
assert_eq!(chat.codex_spend_control_reached, None);
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Idle
|
||||
@@ -1226,18 +1664,16 @@ async fn workspace_member_usage_limit_prompts_and_sends_usage_limit() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn header_rate_limit_snapshot_preserves_member_limit_type_for_error_prompt() {
|
||||
async fn sparse_rate_limit_snapshot_preserves_member_limit_type_for_error_prompt() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let mut usage_limits = snapshot(/*percent*/ 100.0);
|
||||
usage_limits.rate_limit_reached_type =
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
|
||||
chat.on_rate_limit_snapshot(Some(usage_limits));
|
||||
|
||||
// Turn-failure snapshots are derived from response headers and do not carry
|
||||
// the backend-classified reached type. They arrive before the Error event.
|
||||
let mut header_limits = snapshot(/*percent*/ 100.0);
|
||||
header_limits.rate_limit_reached_type = None;
|
||||
chat.on_rate_limit_snapshot(Some(header_limits));
|
||||
let mut rolling_limits = snapshot(/*percent*/ 100.0);
|
||||
rolling_limits.rate_limit_reached_type = None;
|
||||
chat.on_rolling_rate_limit_snapshot(rolling_limits);
|
||||
|
||||
chat.on_rate_limit_error(
|
||||
RateLimitErrorKind::UsageLimit,
|
||||
|
||||
@@ -77,6 +77,7 @@ fn cache_rate_limit_snapshot(chat: &mut ChatWidget) {
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
@@ -247,6 +248,7 @@ async fn status_surface_preview_omits_unavailable_rate_limit_items() {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}));
|
||||
|
||||
@@ -357,25 +357,30 @@ pub(crate) fn format_status_limit_summary(percent_remaining: f64) -> String {
|
||||
format!("{percent_remaining:.0}% left")
|
||||
}
|
||||
|
||||
/// Builds a single `StatusRateLimitRow` for credits when the snapshot indicates
|
||||
/// that the account has credit tracking enabled. When credits are unlimited we
|
||||
/// show that fact explicitly; otherwise we render the rounded balance in
|
||||
/// credits. Accounts with credits = 0 skip this section entirely.
|
||||
/// Builds a single `StatusRateLimitRow` when workspace credits are available.
|
||||
/// Unlimited credits are shown explicitly; finite credits show their rounded
|
||||
/// balance or `Available` when the balance is hidden.
|
||||
fn credit_status_row(credits: &CreditsSnapshotDisplay) -> Option<StatusRateLimitRow> {
|
||||
if !credits.has_credits {
|
||||
return None;
|
||||
}
|
||||
if credits.unlimited {
|
||||
return Some(StatusRateLimitRow {
|
||||
label: "Credits".to_string(),
|
||||
value: StatusRateLimitValue::Text("Unlimited".to_string()),
|
||||
});
|
||||
}
|
||||
let balance = credits.balance.as_ref()?;
|
||||
let display_balance = format_credit_balance(balance)?;
|
||||
if !credits.has_credits {
|
||||
return None;
|
||||
}
|
||||
let value = credits
|
||||
.balance
|
||||
.as_deref()
|
||||
.and_then(format_credit_balance)
|
||||
.map_or_else(
|
||||
|| "Available".to_string(),
|
||||
|display_balance| format!("{display_balance} credits"),
|
||||
);
|
||||
Some(StatusRateLimitRow {
|
||||
label: "Credits".to_string(),
|
||||
value: StatusRateLimitValue::Text(format!("{display_balance} credits")),
|
||||
value: StatusRateLimitValue::Text(value),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -392,6 +397,7 @@ fn format_credit_balance(raw: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
if let Ok(value) = trimmed.parse::<f64>()
|
||||
&& value.is_finite()
|
||||
&& value > 0.0
|
||||
{
|
||||
let rounded = value.round() as i64;
|
||||
|
||||
+1
-1
@@ -19,5 +19,5 @@ expression: sanitized
|
||||
│ Context window: 100% left (2.2K used / 272K) │
|
||||
│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 09:25) │
|
||||
│ Weekly limit: [██████████████░░░░░░] 70% left (resets 09:55) │
|
||||
│ Credits: 38 credits │
|
||||
│ Credits: Available │
|
||||
╰─────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
@@ -288,6 +288,7 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -985,6 +986,7 @@ async fn status_snapshot_includes_monthly_limit() {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1049,6 +1051,7 @@ async fn status_snapshot_includes_enterprise_monthly_credit_limit() {
|
||||
remaining_percent: 68,
|
||||
resets_at: reset_at_from(&captured_at, /*seconds*/ 86_400),
|
||||
}),
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1129,6 +1132,7 @@ async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() {
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1182,6 +1186,7 @@ async fn status_snapshot_shows_unlimited_credits() {
|
||||
balance: None,
|
||||
}),
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1233,6 +1238,7 @@ async fn status_snapshot_shows_positive_credits() {
|
||||
balance: Some("12.5".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1264,7 +1270,7 @@ async fn status_snapshot_shows_positive_credits() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_hides_zero_credits() {
|
||||
async fn status_snapshot_shows_available_credits_without_display_balance() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let config = test_config(&temp_home).await;
|
||||
let account_display = test_status_account_display();
|
||||
@@ -1273,47 +1279,58 @@ async fn status_snapshot_hides_zero_credits() {
|
||||
.with_ymd_and_hms(2024, 4, 5, 6, 7, 8)
|
||||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("0".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
let model_slug = get_model_offline_for_tests(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),
|
||||
for balance in [
|
||||
None,
|
||||
captured_at,
|
||||
&model_slug,
|
||||
/*collaboration_mode*/ None,
|
||||
/*reasoning_effort_override*/ None,
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(/*width*/ 120));
|
||||
assert!(
|
||||
rendered.iter().all(|line| !line.contains("Credits:")),
|
||||
"expected no Credits line, got {rendered:?}"
|
||||
);
|
||||
Some(String::new()),
|
||||
Some("0".to_string()),
|
||||
Some("not-a-number".to_string()),
|
||||
Some("inf".to_string()),
|
||||
] {
|
||||
let snapshot = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance,
|
||||
}),
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
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 rendered = render_lines(&composite.display_lines(/*width*/ 120));
|
||||
assert!(
|
||||
rendered
|
||||
.iter()
|
||||
.any(|line| line.contains("Credits:") && line.contains("Available")),
|
||||
"expected Credits: Available line, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_hides_when_has_no_credits_flag() {
|
||||
async fn status_snapshot_respects_unlimited_without_has_credits_flag() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let config = test_config(&temp_home).await;
|
||||
let account_display = test_status_account_display();
|
||||
@@ -1333,6 +1350,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
|
||||
balance: None,
|
||||
}),
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1356,8 +1374,10 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(/*width*/ 120));
|
||||
assert!(
|
||||
rendered.iter().all(|line| !line.contains("Credits:")),
|
||||
"expected no Credits line when has_credits is false, got {rendered:?}"
|
||||
rendered
|
||||
.iter()
|
||||
.any(|line| line.contains("Credits:") && line.contains("Unlimited")),
|
||||
"expected Credits: Unlimited line, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1440,6 +1460,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1611,6 +1632,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() {
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1680,9 +1702,10 @@ async fn status_snapshot_includes_credits_and_limits() {
|
||||
credits: Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("37.5".to_string()),
|
||||
balance: None,
|
||||
}),
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1738,6 +1761,7 @@ async fn status_snapshot_shows_unavailable_limits_message() {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1796,6 +1820,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1868,6 +1893,7 @@ async fn status_snapshot_shows_stale_limits_message() {
|
||||
}),
|
||||
credits: None,
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
@@ -1940,6 +1966,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
|
||||
balance: Some("80".to_string()),
|
||||
}),
|
||||
individual_limit: None,
|
||||
spend_control_reached: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user