feat: support multiple rate limits (#11260)
Added multi-limit support end-to-end by carrying limit_name in rate-limit snapshots and handling multiple buckets instead of only codex. Extended /usage client parsing to consume additional_rate_limits Updated TUI /status and in-memory state to store/render per-limit snapshots Extended app-server rate-limit read response: kept rate_limits and added rate_limits_by_name. Adjusted usage-limit error messaging for non-default codex limit buckets
This commit is contained in:
parent
641d5268fa
commit
fdd0cd1de9
36 changed files with 1435 additions and 169 deletions
|
|
@ -3470,6 +3470,18 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"limit_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limit_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"plan_type": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4406,6 +4406,18 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"limitId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limitName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"planType": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -4451,6 +4463,18 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"limit_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limit_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"plan_type": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6555,6 +6555,18 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"limit_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limit_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"plan_type": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -11837,7 +11849,22 @@
|
|||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"rateLimits": {
|
||||
"$ref": "#/definitions/v2/RateLimitSnapshot"
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/RateLimitSnapshot"
|
||||
}
|
||||
],
|
||||
"description": "Backward-compatible single-bucket view; mirrors the historical payload."
|
||||
},
|
||||
"rateLimitsByLimitId": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/v2/RateLimitSnapshot"
|
||||
},
|
||||
"description": "Multi-bucket view keyed by metered `limit_id` (for example, `codex`).",
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
|
@ -12852,6 +12879,18 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"limitId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limitName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"planType": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3470,6 +3470,18 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"limit_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limit_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"plan_type": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3470,6 +3470,18 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"limit_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limit_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"plan_type": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3470,6 +3470,18 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"limit_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limit_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"plan_type": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -48,6 +48,18 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"limitId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limitName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"planType": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -48,6 +48,18 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"limitId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limitName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"planType": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -110,7 +122,22 @@
|
|||
},
|
||||
"properties": {
|
||||
"rateLimits": {
|
||||
"$ref": "#/definitions/RateLimitSnapshot"
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RateLimitSnapshot"
|
||||
}
|
||||
],
|
||||
"description": "Backward-compatible single-bucket view; mirrors the historical payload."
|
||||
},
|
||||
"rateLimitsByLimitId": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/RateLimitSnapshot"
|
||||
},
|
||||
"description": "Multi-bucket view keyed by metered `limit_id` (for example, `codex`).",
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@ import type { CreditsSnapshot } from "./CreditsSnapshot";
|
|||
import type { PlanType } from "./PlanType";
|
||||
import type { RateLimitWindow } from "./RateLimitWindow";
|
||||
|
||||
export type RateLimitSnapshot = { primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, plan_type: PlanType | null, };
|
||||
export type RateLimitSnapshot = { limit_id: string | null, limit_name: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, plan_type: PlanType | null, };
|
||||
|
|
|
|||
|
|
@ -3,4 +3,12 @@
|
|||
// 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";
|
||||
|
||||
export type GetAccountRateLimitsResponse = { rateLimits: RateLimitSnapshot, };
|
||||
export type GetAccountRateLimitsResponse = {
|
||||
/**
|
||||
* Backward-compatible single-bucket view; mirrors the historical payload.
|
||||
*/
|
||||
rateLimits: RateLimitSnapshot,
|
||||
/**
|
||||
* Multi-bucket view keyed by metered `limit_id` (for example, `codex`).
|
||||
*/
|
||||
rateLimitsByLimitId: { [key in string]?: RateLimitSnapshot } | null, };
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@ import type { PlanType } from "../PlanType";
|
|||
import type { CreditsSnapshot } from "./CreditsSnapshot";
|
||||
import type { RateLimitWindow } from "./RateLimitWindow";
|
||||
|
||||
export type RateLimitSnapshot = { primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, };
|
||||
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, };
|
||||
|
|
|
|||
|
|
@ -1009,7 +1009,10 @@ pub struct ChatgptAuthTokensRefreshResponse {
|
|||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct GetAccountRateLimitsResponse {
|
||||
/// Backward-compatible single-bucket view; mirrors the historical payload.
|
||||
pub rate_limits: RateLimitSnapshot,
|
||||
/// Multi-bucket view keyed by metered `limit_id` (for example, `codex`).
|
||||
pub rate_limits_by_limit_id: Option<HashMap<String, RateLimitSnapshot>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
|
|
@ -3103,6 +3106,8 @@ pub struct AccountRateLimitsUpdatedNotification {
|
|||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct RateLimitSnapshot {
|
||||
pub limit_id: Option<String>,
|
||||
pub limit_name: Option<String>,
|
||||
pub primary: Option<RateLimitWindow>,
|
||||
pub secondary: Option<RateLimitWindow>,
|
||||
pub credits: Option<CreditsSnapshot>,
|
||||
|
|
@ -3112,6 +3117,8 @@ pub struct RateLimitSnapshot {
|
|||
impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
|
||||
fn from(value: CoreRateLimitSnapshot) -> Self {
|
||||
Self {
|
||||
limit_id: value.limit_id,
|
||||
limit_name: value.limit_name,
|
||||
primary: value.primary.map(RateLimitWindow::from),
|
||||
secondary: value.secondary.map(RateLimitWindow::from),
|
||||
credits: value.credits.map(CreditsSnapshot::from),
|
||||
|
|
|
|||
|
|
@ -2206,6 +2206,8 @@ mod tests {
|
|||
model_context_window: Some(4096),
|
||||
};
|
||||
let rate_limits = RateLimitSnapshot {
|
||||
limit_id: Some("codex".to_string()),
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 42.5,
|
||||
window_minutes: Some(15),
|
||||
|
|
@ -2258,6 +2260,8 @@ mod tests {
|
|||
OutgoingMessage::AppServerNotification(
|
||||
ServerNotification::AccountRateLimitsUpdated(payload),
|
||||
) => {
|
||||
assert_eq!(payload.rate_limits.limit_id.as_deref(), Some("codex"));
|
||||
assert_eq!(payload.rate_limits.limit_name, None);
|
||||
assert!(payload.rate_limits.primary.is_some());
|
||||
assert!(payload.rate_limits.credits.is_some());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1425,9 +1425,15 @@ impl CodexMessageProcessor {
|
|||
|
||||
async fn get_account_rate_limits(&self, request_id: RequestId) {
|
||||
match self.fetch_account_rate_limits().await {
|
||||
Ok(rate_limits) => {
|
||||
Ok((rate_limits, rate_limits_by_limit_id)) => {
|
||||
let response = GetAccountRateLimitsResponse {
|
||||
rate_limits: rate_limits.into(),
|
||||
rate_limits_by_limit_id: Some(
|
||||
rate_limits_by_limit_id
|
||||
.into_iter()
|
||||
.map(|(limit_id, snapshot)| (limit_id, snapshot.into()))
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
|
@ -1437,7 +1443,15 @@ impl CodexMessageProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
async fn fetch_account_rate_limits(&self) -> Result<CoreRateLimitSnapshot, JSONRPCErrorError> {
|
||||
async fn fetch_account_rate_limits(
|
||||
&self,
|
||||
) -> Result<
|
||||
(
|
||||
CoreRateLimitSnapshot,
|
||||
HashMap<String, CoreRateLimitSnapshot>,
|
||||
),
|
||||
JSONRPCErrorError,
|
||||
> {
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return Err(JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
|
|
@ -1461,14 +1475,41 @@ impl CodexMessageProcessor {
|
|||
data: None,
|
||||
})?;
|
||||
|
||||
client
|
||||
.get_rate_limits()
|
||||
let snapshots = client
|
||||
.get_rate_limits_many()
|
||||
.await
|
||||
.map_err(|err| JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to fetch codex rate limits: {err}"),
|
||||
data: None,
|
||||
})?;
|
||||
if snapshots.is_empty() {
|
||||
return Err(JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: "failed to fetch codex rate limits: no snapshots returned".to_string(),
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
|
||||
let rate_limits_by_limit_id: HashMap<String, CoreRateLimitSnapshot> = snapshots
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|snapshot| {
|
||||
let limit_id = snapshot
|
||||
.limit_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "codex".to_string());
|
||||
(limit_id, snapshot)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let primary = snapshots
|
||||
.iter()
|
||||
.find(|snapshot| snapshot.limit_id.as_deref() == Some("codex"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| snapshots[0].clone());
|
||||
|
||||
Ok((primary, rate_limits_by_limit_id))
|
||||
}
|
||||
|
||||
async fn get_user_saved_config(&self, request_id: RequestId) {
|
||||
|
|
|
|||
|
|
@ -283,6 +283,8 @@ mod tests {
|
|||
let notification =
|
||||
ServerNotification::AccountRateLimitsUpdated(AccountRateLimitsUpdatedNotification {
|
||||
rate_limits: RateLimitSnapshot {
|
||||
limit_id: Some("codex".to_string()),
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 25,
|
||||
window_duration_mins: Some(15),
|
||||
|
|
@ -299,7 +301,9 @@ mod tests {
|
|||
json!({
|
||||
"method": "account/rateLimits/updated",
|
||||
"params": {
|
||||
"rateLimits": {
|
||||
"rateLimits": {
|
||||
"limitId": "codex",
|
||||
"limitName": null,
|
||||
"primary": {
|
||||
"usedPercent": 25,
|
||||
"windowDurationMins": 15,
|
||||
|
|
|
|||
|
|
@ -117,7 +117,23 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
|||
"reset_after_seconds": 43200,
|
||||
"reset_at": secondary_reset_timestamp,
|
||||
}
|
||||
}
|
||||
},
|
||||
"additional_rate_limits": [
|
||||
{
|
||||
"limit_name": "codex_other",
|
||||
"metered_feature": "codex_other",
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {
|
||||
"used_percent": 88,
|
||||
"limit_window_seconds": 1800,
|
||||
"reset_after_seconds": 600,
|
||||
"reset_at": 1735693200
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
Mock::given(method("GET"))
|
||||
|
|
@ -143,6 +159,8 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
|||
|
||||
let expected = GetAccountRateLimitsResponse {
|
||||
rate_limits: RateLimitSnapshot {
|
||||
limit_id: Some("codex".to_string()),
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 42,
|
||||
window_duration_mins: Some(60),
|
||||
|
|
@ -156,6 +174,46 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
|||
credits: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
},
|
||||
rate_limits_by_limit_id: Some(
|
||||
[
|
||||
(
|
||||
"codex".to_string(),
|
||||
RateLimitSnapshot {
|
||||
limit_id: Some("codex".to_string()),
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 42,
|
||||
window_duration_mins: Some(60),
|
||||
resets_at: Some(primary_reset_timestamp),
|
||||
}),
|
||||
secondary: Some(RateLimitWindow {
|
||||
used_percent: 5,
|
||||
window_duration_mins: Some(1440),
|
||||
resets_at: Some(secondary_reset_timestamp),
|
||||
}),
|
||||
credits: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
},
|
||||
),
|
||||
(
|
||||
"codex_other".to_string(),
|
||||
RateLimitSnapshot {
|
||||
limit_id: Some("codex_other".to_string()),
|
||||
limit_name: Some("codex_other".to_string()),
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 88,
|
||||
window_duration_mins: Some(30),
|
||||
resets_at: Some(1735693200),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
},
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
assert_eq!(received, expected);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
use crate::types::CodeTaskDetailsResponse;
|
||||
use crate::types::ConfigFileResponse;
|
||||
use crate::types::CreditStatusDetails;
|
||||
use crate::types::PaginatedListTaskListItem;
|
||||
use crate::types::RateLimitStatusPayload;
|
||||
use crate::types::RateLimitWindowSnapshot;
|
||||
use crate::types::TurnAttemptsSiblingTurnsResponse;
|
||||
use anyhow::Result;
|
||||
use codex_core::auth::CodexAuth;
|
||||
|
|
@ -160,6 +158,15 @@ impl Client {
|
|||
}
|
||||
|
||||
pub async fn get_rate_limits(&self) -> Result<RateLimitSnapshot> {
|
||||
let snapshots = self.get_rate_limits_many().await?;
|
||||
let preferred = snapshots
|
||||
.iter()
|
||||
.find(|snapshot| snapshot.limit_id.as_deref() == Some("codex"))
|
||||
.cloned();
|
||||
Ok(preferred.unwrap_or_else(|| snapshots[0].clone()))
|
||||
}
|
||||
|
||||
pub async fn get_rate_limits_many(&self) -> Result<Vec<RateLimitSnapshot>> {
|
||||
let url = match self.path_style {
|
||||
PathStyle::CodexApi => format!("{}/api/codex/usage", self.base_url),
|
||||
PathStyle::ChatGptApi => format!("{}/wham/usage", self.base_url),
|
||||
|
|
@ -167,7 +174,7 @@ impl Client {
|
|||
let req = self.http.get(&url).headers(self.headers());
|
||||
let (body, ct) = self.exec_request(req, "GET", &url).await?;
|
||||
let payload: RateLimitStatusPayload = self.decode_json(&url, &ct, &body)?;
|
||||
Ok(Self::rate_limit_snapshot_from_payload(payload))
|
||||
Ok(Self::rate_limit_snapshots_from_payload(payload))
|
||||
}
|
||||
|
||||
pub async fn list_tasks(
|
||||
|
|
@ -295,35 +302,59 @@ impl Client {
|
|||
}
|
||||
|
||||
// rate limit helpers
|
||||
fn rate_limit_snapshot_from_payload(payload: RateLimitStatusPayload) -> RateLimitSnapshot {
|
||||
let rate_limit_details = payload
|
||||
.rate_limit
|
||||
.and_then(|inner| inner.map(|boxed| *boxed));
|
||||
fn rate_limit_snapshots_from_payload(
|
||||
payload: RateLimitStatusPayload,
|
||||
) -> Vec<RateLimitSnapshot> {
|
||||
let plan_type = Some(Self::map_plan_type(payload.plan_type));
|
||||
let mut snapshots = vec![Self::make_rate_limit_snapshot(
|
||||
Some("codex".to_string()),
|
||||
None,
|
||||
payload.rate_limit.flatten().map(|details| *details),
|
||||
payload.credits.flatten().map(|details| *details),
|
||||
plan_type,
|
||||
)];
|
||||
if let Some(additional) = payload.additional_rate_limits.flatten() {
|
||||
snapshots.extend(additional.into_iter().map(|details| {
|
||||
Self::make_rate_limit_snapshot(
|
||||
Some(details.metered_feature),
|
||||
Some(details.limit_name),
|
||||
details.rate_limit.flatten().map(|rate_limit| *rate_limit),
|
||||
None,
|
||||
plan_type,
|
||||
)
|
||||
}));
|
||||
}
|
||||
snapshots
|
||||
}
|
||||
|
||||
let (primary, secondary) = if let Some(details) = rate_limit_details {
|
||||
(
|
||||
fn make_rate_limit_snapshot(
|
||||
limit_id: Option<String>,
|
||||
limit_name: Option<String>,
|
||||
rate_limit: Option<crate::types::RateLimitStatusDetails>,
|
||||
credits: Option<crate::types::CreditStatusDetails>,
|
||||
plan_type: Option<AccountPlanType>,
|
||||
) -> RateLimitSnapshot {
|
||||
let (primary, secondary) = match rate_limit {
|
||||
Some(details) => (
|
||||
Self::map_rate_limit_window(details.primary_window),
|
||||
Self::map_rate_limit_window(details.secondary_window),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
),
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
RateLimitSnapshot {
|
||||
limit_id,
|
||||
limit_name,
|
||||
primary,
|
||||
secondary,
|
||||
credits: Self::map_credits(payload.credits),
|
||||
plan_type: Some(Self::map_plan_type(payload.plan_type)),
|
||||
credits: Self::map_credits(credits),
|
||||
plan_type,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_rate_limit_window(
|
||||
window: Option<Option<Box<RateLimitWindowSnapshot>>>,
|
||||
window: Option<Option<Box<crate::types::RateLimitWindowSnapshot>>>,
|
||||
) -> Option<RateLimitWindow> {
|
||||
let snapshot = match window {
|
||||
Some(Some(snapshot)) => *snapshot,
|
||||
_ => return None,
|
||||
};
|
||||
let snapshot = window.flatten().map(|details| *details)?;
|
||||
|
||||
let used_percent = f64::from(snapshot.used_percent);
|
||||
let window_minutes = Self::window_minutes_from_seconds(snapshot.limit_window_seconds);
|
||||
|
|
@ -335,16 +366,13 @@ impl Client {
|
|||
})
|
||||
}
|
||||
|
||||
fn map_credits(credits: Option<Option<Box<CreditStatusDetails>>>) -> Option<CreditsSnapshot> {
|
||||
let details = match credits {
|
||||
Some(Some(details)) => *details,
|
||||
_ => return None,
|
||||
};
|
||||
fn map_credits(credits: Option<crate::types::CreditStatusDetails>) -> Option<CreditsSnapshot> {
|
||||
let details = credits?;
|
||||
|
||||
Some(CreditsSnapshot {
|
||||
has_credits: details.has_credits,
|
||||
unlimited: details.unlimited,
|
||||
balance: details.balance.and_then(|inner| inner),
|
||||
balance: details.balance.flatten(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -374,3 +402,142 @@ impl Client {
|
|||
Some((seconds_i64 + 59) / 60)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn usage_payload_maps_primary_and_additional_rate_limits() {
|
||||
let payload = RateLimitStatusPayload {
|
||||
plan_type: crate::types::PlanType::Pro,
|
||||
rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails {
|
||||
primary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot {
|
||||
used_percent: 42,
|
||||
limit_window_seconds: 300,
|
||||
reset_after_seconds: 0,
|
||||
reset_at: 123,
|
||||
}))),
|
||||
secondary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot {
|
||||
used_percent: 84,
|
||||
limit_window_seconds: 3600,
|
||||
reset_after_seconds: 0,
|
||||
reset_at: 456,
|
||||
}))),
|
||||
..Default::default()
|
||||
}))),
|
||||
additional_rate_limits: Some(Some(vec![crate::types::AdditionalRateLimitDetails {
|
||||
limit_name: "codex_other".to_string(),
|
||||
metered_feature: "codex_other".to_string(),
|
||||
rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails {
|
||||
primary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot {
|
||||
used_percent: 70,
|
||||
limit_window_seconds: 900,
|
||||
reset_after_seconds: 0,
|
||||
reset_at: 789,
|
||||
}))),
|
||||
secondary_window: None,
|
||||
..Default::default()
|
||||
}))),
|
||||
}])),
|
||||
credits: Some(Some(Box::new(crate::types::CreditStatusDetails {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some(Some("9.99".to_string())),
|
||||
..Default::default()
|
||||
}))),
|
||||
};
|
||||
|
||||
let snapshots = Client::rate_limit_snapshots_from_payload(payload);
|
||||
assert_eq!(snapshots.len(), 2);
|
||||
|
||||
assert_eq!(snapshots[0].limit_id.as_deref(), Some("codex"));
|
||||
assert_eq!(snapshots[0].limit_name, None);
|
||||
assert_eq!(
|
||||
snapshots[0].primary.as_ref().map(|w| w.used_percent),
|
||||
Some(42.0)
|
||||
);
|
||||
assert_eq!(
|
||||
snapshots[0].secondary.as_ref().map(|w| w.used_percent),
|
||||
Some(84.0)
|
||||
);
|
||||
assert_eq!(
|
||||
snapshots[0].credits,
|
||||
Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("9.99".to_string()),
|
||||
})
|
||||
);
|
||||
assert_eq!(snapshots[0].plan_type, Some(AccountPlanType::Pro));
|
||||
|
||||
assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other"));
|
||||
assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other"));
|
||||
assert_eq!(
|
||||
snapshots[1].primary.as_ref().map(|w| w.used_percent),
|
||||
Some(70.0)
|
||||
);
|
||||
assert_eq!(snapshots[1].credits, None);
|
||||
assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_payload_maps_zero_rate_limit_when_primary_absent() {
|
||||
let payload = RateLimitStatusPayload {
|
||||
plan_type: crate::types::PlanType::Plus,
|
||||
rate_limit: None,
|
||||
additional_rate_limits: Some(Some(vec![crate::types::AdditionalRateLimitDetails {
|
||||
limit_name: "codex_other".to_string(),
|
||||
metered_feature: "codex_other".to_string(),
|
||||
rate_limit: None,
|
||||
}])),
|
||||
credits: None,
|
||||
};
|
||||
|
||||
let snapshots = Client::rate_limit_snapshots_from_payload(payload);
|
||||
assert_eq!(snapshots.len(), 2);
|
||||
assert_eq!(snapshots[0].limit_id.as_deref(), Some("codex"));
|
||||
assert_eq!(snapshots[0].limit_name, None);
|
||||
assert_eq!(snapshots[0].primary, None);
|
||||
assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other"));
|
||||
assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_snapshot_selection_matches_get_rate_limits_behavior() {
|
||||
let snapshots = [
|
||||
RateLimitSnapshot {
|
||||
limit_id: Some("codex_other".to_string()),
|
||||
limit_name: Some("codex_other".to_string()),
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 90.0,
|
||||
window_minutes: Some(60),
|
||||
resets_at: Some(1),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
},
|
||||
RateLimitSnapshot {
|
||||
limit_id: Some("codex".to_string()),
|
||||
limit_name: Some("codex".to_string()),
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 10.0,
|
||||
window_minutes: Some(60),
|
||||
resets_at: Some(2),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
},
|
||||
];
|
||||
|
||||
let preferred = snapshots
|
||||
.iter()
|
||||
.find(|snapshot| snapshot.limit_id.as_deref() == Some("codex"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| snapshots[0].clone());
|
||||
assert_eq!(preferred.limit_id.as_deref(), Some("codex"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub use codex_backend_openapi_models::models::AdditionalRateLimitDetails;
|
||||
pub use codex_backend_openapi_models::models::ConfigFileResponse;
|
||||
pub use codex_backend_openapi_models::models::CreditStatusDetails;
|
||||
pub use codex_backend_openapi_models::models::PaginatedListTaskListItem;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use codex_protocol::protocol::RateLimitSnapshot;
|
|||
use codex_protocol::protocol::RateLimitWindow;
|
||||
use http::HeaderMap;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt::Display;
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -17,25 +18,77 @@ impl Display for RateLimitError {
|
|||
}
|
||||
}
|
||||
|
||||
/// Parses the bespoke Codex rate-limit headers into a `RateLimitSnapshot`.
|
||||
pub fn parse_rate_limit(headers: &HeaderMap) -> Option<RateLimitSnapshot> {
|
||||
/// Parses the default Codex rate-limit header family into a `RateLimitSnapshot`.
|
||||
pub fn parse_default_rate_limit(headers: &HeaderMap) -> Option<RateLimitSnapshot> {
|
||||
parse_rate_limit_for_limit(headers, None)
|
||||
}
|
||||
|
||||
/// Parses all known rate-limit header families into update records keyed by limit id.
|
||||
pub fn parse_all_rate_limits(headers: &HeaderMap) -> Vec<RateLimitSnapshot> {
|
||||
let mut snapshots = Vec::new();
|
||||
if let Some(snapshot) = parse_default_rate_limit(headers) {
|
||||
snapshots.push(snapshot);
|
||||
}
|
||||
|
||||
let mut limit_ids: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
for name in headers.keys() {
|
||||
let header_name = name.as_str().to_ascii_lowercase();
|
||||
if let Some(limit_id) = header_name_to_limit_id(&header_name)
|
||||
&& limit_id != "codex"
|
||||
{
|
||||
limit_ids.insert(limit_id);
|
||||
}
|
||||
}
|
||||
|
||||
snapshots.extend(limit_ids.into_iter().filter_map(|limit_id| {
|
||||
let snapshot = parse_rate_limit_for_limit(headers, Some(limit_id.as_str()))?;
|
||||
has_rate_limit_data(&snapshot).then_some(snapshot)
|
||||
}));
|
||||
|
||||
snapshots
|
||||
}
|
||||
|
||||
/// Parses rate-limit headers for the provided limit id.
|
||||
///
|
||||
/// `limit_id` should match the server-provided metered limit id (e.g. `codex`,
|
||||
/// `codex_other`). When omitted, this defaults to the legacy `codex` header family.
|
||||
pub fn parse_rate_limit_for_limit(
|
||||
headers: &HeaderMap,
|
||||
limit_id: Option<&str>,
|
||||
) -> Option<RateLimitSnapshot> {
|
||||
let normalized_limit = limit_id
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or("codex")
|
||||
.to_ascii_lowercase()
|
||||
.replace('_', "-");
|
||||
let prefix = format!("x-{normalized_limit}");
|
||||
let primary = parse_rate_limit_window(
|
||||
headers,
|
||||
"x-codex-primary-used-percent",
|
||||
"x-codex-primary-window-minutes",
|
||||
"x-codex-primary-reset-at",
|
||||
&format!("{prefix}-primary-used-percent"),
|
||||
&format!("{prefix}-primary-window-minutes"),
|
||||
&format!("{prefix}-primary-reset-at"),
|
||||
);
|
||||
|
||||
let secondary = parse_rate_limit_window(
|
||||
headers,
|
||||
"x-codex-secondary-used-percent",
|
||||
"x-codex-secondary-window-minutes",
|
||||
"x-codex-secondary-reset-at",
|
||||
&format!("{prefix}-secondary-used-percent"),
|
||||
&format!("{prefix}-secondary-window-minutes"),
|
||||
&format!("{prefix}-secondary-reset-at"),
|
||||
);
|
||||
|
||||
let normalized_limit_id = normalize_limit_id(normalized_limit);
|
||||
let credits = parse_credits_snapshot(headers);
|
||||
let limit_name_header = format!("{prefix}-limit-name");
|
||||
let parsed_limit_name = parse_header_str(headers, &limit_name_header)
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(std::string::ToString::to_string);
|
||||
|
||||
Some(RateLimitSnapshot {
|
||||
limit_id: Some(normalized_limit_id),
|
||||
limit_name: parsed_limit_name,
|
||||
primary,
|
||||
secondary,
|
||||
credits,
|
||||
|
|
@ -70,6 +123,8 @@ struct RateLimitEvent {
|
|||
plan_type: Option<PlanType>,
|
||||
rate_limits: Option<RateLimitEventDetails>,
|
||||
credits: Option<RateLimitEventCredits>,
|
||||
metered_limit_name: Option<String>,
|
||||
limit_name: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_rate_limit_event(payload: &str) -> Option<RateLimitSnapshot> {
|
||||
|
|
@ -90,7 +145,13 @@ pub fn parse_rate_limit_event(payload: &str) -> Option<RateLimitSnapshot> {
|
|||
unlimited: credits.unlimited,
|
||||
balance: credits.balance,
|
||||
});
|
||||
let limit_id = event
|
||||
.metered_limit_name
|
||||
.or(event.limit_name)
|
||||
.map(normalize_limit_id);
|
||||
Some(RateLimitSnapshot {
|
||||
limit_id: Some(limit_id.unwrap_or_else(|| "codex".to_string())),
|
||||
limit_name: None,
|
||||
primary,
|
||||
secondary,
|
||||
credits,
|
||||
|
|
@ -178,3 +239,128 @@ fn parse_header_bool(headers: &HeaderMap, name: &str) -> Option<bool> {
|
|||
fn parse_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
headers.get(name)?.to_str().ok()
|
||||
}
|
||||
|
||||
fn has_rate_limit_data(snapshot: &RateLimitSnapshot) -> bool {
|
||||
snapshot.primary.is_some() || snapshot.secondary.is_some() || snapshot.credits.is_some()
|
||||
}
|
||||
|
||||
fn header_name_to_limit_id(header_name: &str) -> Option<String> {
|
||||
let suffix = "-primary-used-percent";
|
||||
let prefix = header_name.strip_suffix(suffix)?;
|
||||
let limit = prefix.strip_prefix("x-")?;
|
||||
Some(normalize_limit_id(limit.to_string()))
|
||||
}
|
||||
|
||||
fn normalize_limit_id(name: impl Into<String>) -> String {
|
||||
name.into().trim().to_ascii_lowercase().replace('-', "_")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http::HeaderValue;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parse_rate_limit_for_limit_defaults_to_codex_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-codex-primary-used-percent",
|
||||
HeaderValue::from_static("12.5"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-codex-primary-window-minutes",
|
||||
HeaderValue::from_static("60"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-codex-primary-reset-at",
|
||||
HeaderValue::from_static("1704069000"),
|
||||
);
|
||||
|
||||
let snapshot = parse_rate_limit_for_limit(&headers, None).expect("snapshot");
|
||||
assert_eq!(snapshot.limit_id.as_deref(), Some("codex"));
|
||||
assert_eq!(snapshot.limit_name, None);
|
||||
let primary = snapshot.primary.expect("primary");
|
||||
assert_eq!(primary.used_percent, 12.5);
|
||||
assert_eq!(primary.window_minutes, Some(60));
|
||||
assert_eq!(primary.resets_at, Some(1704069000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rate_limit_for_limit_reads_secondary_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-codex-secondary-primary-used-percent",
|
||||
HeaderValue::from_static("80"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-codex-secondary-primary-window-minutes",
|
||||
HeaderValue::from_static("1440"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-codex-secondary-primary-reset-at",
|
||||
HeaderValue::from_static("1704074400"),
|
||||
);
|
||||
|
||||
let snapshot =
|
||||
parse_rate_limit_for_limit(&headers, Some("codex_secondary")).expect("snapshot");
|
||||
assert_eq!(snapshot.limit_id.as_deref(), Some("codex_secondary"));
|
||||
assert_eq!(snapshot.limit_name, None);
|
||||
let primary = snapshot.primary.expect("primary");
|
||||
assert_eq!(primary.used_percent, 80.0);
|
||||
assert_eq!(primary.window_minutes, Some(1440));
|
||||
assert_eq!(primary.resets_at, Some(1704074400));
|
||||
assert_eq!(snapshot.secondary, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rate_limit_for_limit_prefers_limit_name_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-codex-bengalfox-primary-used-percent",
|
||||
HeaderValue::from_static("80"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-codex-bengalfox-limit-name",
|
||||
HeaderValue::from_static("gpt-5.2-codex-sonic"),
|
||||
);
|
||||
|
||||
let snapshot =
|
||||
parse_rate_limit_for_limit(&headers, Some("codex_bengalfox")).expect("snapshot");
|
||||
assert_eq!(snapshot.limit_id.as_deref(), Some("codex_bengalfox"));
|
||||
assert_eq!(snapshot.limit_name.as_deref(), Some("gpt-5.2-codex-sonic"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_all_rate_limits_reads_all_limit_families() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-codex-primary-used-percent",
|
||||
HeaderValue::from_static("12.5"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-codex-secondary-primary-used-percent",
|
||||
HeaderValue::from_static("80"),
|
||||
);
|
||||
|
||||
let updates = parse_all_rate_limits(&headers);
|
||||
assert_eq!(updates.len(), 2);
|
||||
assert_eq!(updates[0].limit_id.as_deref(), Some("codex"));
|
||||
assert_eq!(updates[1].limit_id.as_deref(), Some("codex_secondary"));
|
||||
assert_eq!(updates[0].limit_name, None);
|
||||
assert_eq!(updates[1].limit_name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_all_rate_limits_includes_default_codex_snapshot() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
let updates = parse_all_rate_limits(&headers);
|
||||
assert_eq!(updates.len(), 1);
|
||||
assert_eq!(updates[0].limit_id.as_deref(), Some("codex"));
|
||||
assert_eq!(updates[0].limit_name, None);
|
||||
assert_eq!(updates[0].primary, None);
|
||||
assert_eq!(updates[0].secondary, None);
|
||||
assert_eq!(updates[0].credits, None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::common::ResponseEvent;
|
||||
use crate::common::ResponseStream;
|
||||
use crate::error::ApiError;
|
||||
use crate::rate_limits::parse_rate_limit;
|
||||
use crate::rate_limits::parse_all_rate_limits;
|
||||
use crate::telemetry::SseTelemetry;
|
||||
use codex_client::ByteStream;
|
||||
use codex_client::StreamResponse;
|
||||
|
|
@ -54,7 +54,7 @@ pub fn spawn_response_stream(
|
|||
telemetry: Option<Arc<dyn SseTelemetry>>,
|
||||
turn_state: Option<Arc<OnceLock<String>>>,
|
||||
) -> ResponseStream {
|
||||
let rate_limits = parse_rate_limit(&stream_response.headers);
|
||||
let rate_limit_snapshots = parse_all_rate_limits(&stream_response.headers);
|
||||
let models_etag = stream_response
|
||||
.headers
|
||||
.get("X-Models-Etag")
|
||||
|
|
@ -74,7 +74,7 @@ pub fn spawn_response_stream(
|
|||
}
|
||||
let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent, ApiError>>(1600);
|
||||
tokio::spawn(async move {
|
||||
if let Some(snapshot) = rate_limits {
|
||||
for snapshot in rate_limit_snapshots {
|
||||
let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await;
|
||||
}
|
||||
if let Some(etag) = models_etag {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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 AdditionalRateLimitDetails {
|
||||
#[serde(rename = "limit_name")]
|
||||
pub limit_name: String,
|
||||
#[serde(rename = "metered_feature")]
|
||||
pub metered_feature: String,
|
||||
#[serde(
|
||||
rename = "rate_limit",
|
||||
default,
|
||||
with = "::serde_with::rust::double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub rate_limit: Option<Option<Box<models::RateLimitStatusDetails>>>,
|
||||
}
|
||||
|
||||
impl AdditionalRateLimitDetails {
|
||||
pub fn new(limit_name: String, metered_feature: String) -> AdditionalRateLimitDetails {
|
||||
AdditionalRateLimitDetails {
|
||||
limit_name,
|
||||
metered_feature,
|
||||
rate_limit: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,9 @@ pub mod paginated_list_task_list_item_;
|
|||
pub use self::paginated_list_task_list_item_::PaginatedListTaskListItem;
|
||||
|
||||
// Rate Limits
|
||||
pub mod additional_rate_limit_details;
|
||||
pub use self::additional_rate_limit_details::AdditionalRateLimitDetails;
|
||||
|
||||
pub mod rate_limit_status_payload;
|
||||
pub use self::rate_limit_status_payload::PlanType;
|
||||
pub use self::rate_limit_status_payload::RateLimitStatusPayload;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,13 @@ pub struct RateLimitStatusPayload {
|
|||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub credits: Option<Option<Box<models::CreditStatusDetails>>>,
|
||||
#[serde(
|
||||
rename = "additional_rate_limits",
|
||||
default,
|
||||
with = "::serde_with::rust::double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub additional_rate_limits: Option<Option<Vec<models::AdditionalRateLimitDetails>>>,
|
||||
}
|
||||
|
||||
impl RateLimitStatusPayload {
|
||||
|
|
@ -38,6 +45,7 @@ impl RateLimitStatusPayload {
|
|||
plan_type,
|
||||
rate_limit: None,
|
||||
credits: None,
|
||||
additional_rate_limits: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use codex_api::AuthProvider as ApiAuthProvider;
|
|||
use codex_api::TransportError;
|
||||
use codex_api::error::ApiError;
|
||||
use codex_api::rate_limits::parse_promo_message;
|
||||
use codex_api::rate_limits::parse_rate_limit;
|
||||
use codex_api::rate_limits::parse_rate_limit_for_limit;
|
||||
use http::HeaderMap;
|
||||
use serde::Deserialize;
|
||||
|
||||
|
|
@ -71,7 +71,10 @@ pub(crate) 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 rate_limits = headers.as_ref().and_then(parse_rate_limit);
|
||||
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 resets_at = err
|
||||
.error
|
||||
|
|
@ -80,8 +83,9 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
|
|||
return CodexErr::UsageLimitReached(UsageLimitReachedError {
|
||||
plan_type: err.error.plan_type,
|
||||
resets_at,
|
||||
rate_limits,
|
||||
rate_limits: rate_limits.map(Box::new),
|
||||
promo_message,
|
||||
limit_name: limit_id,
|
||||
});
|
||||
} else if err.error.error_type.as_deref() == Some("usage_not_included") {
|
||||
return CodexErr::UsageNotIncluded;
|
||||
|
|
@ -117,6 +121,7 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
|
|||
|
||||
const MODEL_CAP_MODEL_HEADER: &str = "x-codex-model-cap-model";
|
||||
const MODEL_CAP_RESET_AFTER_HEADER: &str = "x-codex-model-cap-reset-after-seconds";
|
||||
const ACTIVE_LIMIT_HEADER: &str = "x-codex-active-limit";
|
||||
const REQUEST_ID_HEADER: &str = "x-request-id";
|
||||
const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id";
|
||||
const CF_RAY_HEADER: &str = "cf-ray";
|
||||
|
|
@ -152,6 +157,33 @@ mod tests {
|
|||
assert_eq!(model_cap.model, "boomslang");
|
||||
assert_eq!(model_cap.reset_after_seconds, Some(120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_maps_usage_limit_limit_name_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
ACTIVE_LIMIT_HEADER,
|
||||
http::HeaderValue::from_static("codex_other"),
|
||||
);
|
||||
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: 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.limit_name.as_deref(), Some("codex_other"));
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_request_tracking_id(headers: Option<&HeaderMap>) -> Option<String> {
|
||||
|
|
|
|||
|
|
@ -4280,7 +4280,7 @@ async fn run_sampling_request(
|
|||
Err(CodexErr::UsageLimitReached(e)) => {
|
||||
let rate_limits = e.rate_limits.clone();
|
||||
if let Some(rate_limits) = rate_limits {
|
||||
sess.update_rate_limits(&turn_context, rate_limits).await;
|
||||
sess.update_rate_limits(&turn_context, *rate_limits).await;
|
||||
}
|
||||
return Err(CodexErr::UsageLimitReached(e));
|
||||
}
|
||||
|
|
@ -5823,6 +5823,8 @@ mod tests {
|
|||
|
||||
let mut state = SessionState::new(session_configuration);
|
||||
let initial = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 10.0,
|
||||
window_minutes: Some(15),
|
||||
|
|
@ -5839,6 +5841,8 @@ mod tests {
|
|||
state.set_rate_limits(initial.clone());
|
||||
|
||||
let update = RateLimitSnapshot {
|
||||
limit_id: Some("codex_other".to_string()),
|
||||
limit_name: Some("codex_other".to_string()),
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 40.0,
|
||||
window_minutes: Some(30),
|
||||
|
|
@ -5857,6 +5861,8 @@ mod tests {
|
|||
assert_eq!(
|
||||
state.latest_rate_limits,
|
||||
Some(RateLimitSnapshot {
|
||||
limit_id: Some("codex_other".to_string()),
|
||||
limit_name: Some("codex_other".to_string()),
|
||||
primary: update.primary.clone(),
|
||||
secondary: update.secondary,
|
||||
credits: initial.credits,
|
||||
|
|
@ -5906,6 +5912,8 @@ mod tests {
|
|||
|
||||
let mut state = SessionState::new(session_configuration);
|
||||
let initial = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 15.0,
|
||||
window_minutes: Some(20),
|
||||
|
|
@ -5926,6 +5934,8 @@ mod tests {
|
|||
state.set_rate_limits(initial.clone());
|
||||
|
||||
let update = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 35.0,
|
||||
window_minutes: Some(25),
|
||||
|
|
@ -5940,6 +5950,8 @@ mod tests {
|
|||
assert_eq!(
|
||||
state.latest_rate_limits,
|
||||
Some(RateLimitSnapshot {
|
||||
limit_id: Some("codex".to_string()),
|
||||
limit_name: None,
|
||||
primary: update.primary,
|
||||
secondary: update.secondary,
|
||||
credits: initial.credits,
|
||||
|
|
|
|||
|
|
@ -409,12 +409,23 @@ impl std::fmt::Display for RetryLimitReachedError {
|
|||
pub struct UsageLimitReachedError {
|
||||
pub(crate) plan_type: Option<PlanType>,
|
||||
pub(crate) resets_at: Option<DateTime<Utc>>,
|
||||
pub(crate) rate_limits: Option<RateLimitSnapshot>,
|
||||
pub(crate) rate_limits: Option<Box<RateLimitSnapshot>>,
|
||||
pub(crate) promo_message: Option<String>,
|
||||
pub(crate) limit_name: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UsageLimitReachedError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(limit_name) = self.limit_name.as_deref()
|
||||
&& !limit_name.eq_ignore_ascii_case("codex")
|
||||
{
|
||||
return write!(
|
||||
f,
|
||||
"You've hit your usage limit for {limit_name}.{}",
|
||||
retry_suffix(self.resets_at.as_ref())
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(promo_message) = &self.promo_message {
|
||||
return write!(
|
||||
f,
|
||||
|
|
@ -699,6 +710,8 @@ mod tests {
|
|||
.unwrap()
|
||||
.timestamp();
|
||||
RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 50.0,
|
||||
window_minutes: Some(60),
|
||||
|
|
@ -728,8 +741,9 @@ mod tests {
|
|||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
|
|
@ -875,8 +889,9 @@ mod tests {
|
|||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Free)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
|
|
@ -889,8 +904,9 @@ mod tests {
|
|||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Go)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
|
|
@ -903,8 +919,9 @@ mod tests {
|
|||
let err = UsageLimitReachedError {
|
||||
plan_type: None,
|
||||
resets_at: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
|
|
@ -921,8 +938,9 @@ mod tests {
|
|||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Team)),
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}."
|
||||
|
|
@ -936,8 +954,9 @@ mod tests {
|
|||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Business)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
|
|
@ -950,8 +969,9 @@ mod tests {
|
|||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Enterprise)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
|
|
@ -968,8 +988,9 @@ mod tests {
|
|||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Pro)),
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
|
||||
|
|
@ -978,6 +999,29 @@ mod tests {
|
|||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_hides_upsell_for_non_codex_limit_name() {
|
||||
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
||||
let resets_at = base + ChronoDuration::hours(1);
|
||||
with_now_override(base, move || {
|
||||
let expected_time = format_retry_timestamp(&resets_at);
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: Some(
|
||||
"Visit https://chatgpt.com/codex/settings/usage to purchase more credits"
|
||||
.to_string(),
|
||||
),
|
||||
limit_name: Some("codex_other".to_string()),
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit for codex_other. Try again at {expected_time}."
|
||||
);
|
||||
assert_eq!(err.to_string(), expected);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_includes_minutes_when_available() {
|
||||
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
||||
|
|
@ -987,8 +1031,9 @@ mod tests {
|
|||
let err = UsageLimitReachedError {
|
||||
plan_type: None,
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
||||
assert_eq!(err.to_string(), expected);
|
||||
|
|
@ -1096,8 +1141,9 @@ mod tests {
|
|||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
|
||||
|
|
@ -1116,8 +1162,9 @@ mod tests {
|
|||
let err = UsageLimitReachedError {
|
||||
plan_type: None,
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
||||
assert_eq!(err.to_string(), expected);
|
||||
|
|
@ -1133,8 +1180,9 @@ mod tests {
|
|||
let err = UsageLimitReachedError {
|
||||
plan_type: None,
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
limit_name: None,
|
||||
};
|
||||
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
||||
assert_eq!(err.to_string(), expected);
|
||||
|
|
@ -1150,10 +1198,11 @@ mod tests {
|
|||
let err = UsageLimitReachedError {
|
||||
plan_type: None,
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: Some(
|
||||
"To continue using Codex, start a free trial of <PLAN> today".to_string(),
|
||||
),
|
||||
limit_name: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. To continue using Codex, start a free trial of <PLAN> today, or try again at {expected_time}."
|
||||
|
|
|
|||
|
|
@ -170,11 +170,21 @@ impl SessionState {
|
|||
}
|
||||
}
|
||||
|
||||
// Sometimes new snapshots don't include credits or plan information.
|
||||
// Merge partial rate-limit updates: new fields overwrite existing values;
|
||||
// missing fields retain prior values. If `limit_id` is absent everywhere,
|
||||
// default it to `"codex"`.
|
||||
fn merge_rate_limit_fields(
|
||||
previous: Option<&RateLimitSnapshot>,
|
||||
mut snapshot: RateLimitSnapshot,
|
||||
) -> RateLimitSnapshot {
|
||||
if snapshot.limit_id.is_none() {
|
||||
snapshot.limit_id = previous
|
||||
.and_then(|prior| prior.limit_id.clone())
|
||||
.or_else(|| Some("codex".to_string()));
|
||||
}
|
||||
if snapshot.limit_name.is_none() {
|
||||
snapshot.limit_name = previous.and_then(|prior| prior.limit_name.clone());
|
||||
}
|
||||
if snapshot.credits.is_none() {
|
||||
snapshot.credits = previous.and_then(|prior| prior.credits.clone());
|
||||
}
|
||||
|
|
@ -188,6 +198,7 @@ fn merge_rate_limit_fields(
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::codex::make_session_configuration_for_tests;
|
||||
use crate::protocol::RateLimitWindow;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -258,4 +269,126 @@ mod tests {
|
|||
|
||||
assert_eq!(state.get_mcp_tool_selection(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() {
|
||||
let session_configuration = make_session_configuration_for_tests().await;
|
||||
let mut state = SessionState::new(session_configuration);
|
||||
|
||||
state.set_rate_limits(RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 12.0,
|
||||
window_minutes: Some(60),
|
||||
resets_at: Some(100),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.latest_rate_limits
|
||||
.as_ref()
|
||||
.and_then(|v| v.limit_id.clone()),
|
||||
Some("codex".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_rate_limits_preserves_previous_limit_id_when_missing() {
|
||||
let session_configuration = make_session_configuration_for_tests().await;
|
||||
let mut state = SessionState::new(session_configuration);
|
||||
|
||||
state.set_rate_limits(RateLimitSnapshot {
|
||||
limit_id: Some("codex_other".to_string()),
|
||||
limit_name: Some("codex_other".to_string()),
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 20.0,
|
||||
window_minutes: Some(60),
|
||||
resets_at: Some(200),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
});
|
||||
state.set_rate_limits(RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 30.0,
|
||||
window_minutes: Some(60),
|
||||
resets_at: Some(300),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.latest_rate_limits
|
||||
.as_ref()
|
||||
.and_then(|v| v.limit_id.clone()),
|
||||
Some("codex_other".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_rate_limits_accepts_new_limit_id_bucket() {
|
||||
let session_configuration = make_session_configuration_for_tests().await;
|
||||
let mut state = SessionState::new(session_configuration);
|
||||
|
||||
state.set_rate_limits(RateLimitSnapshot {
|
||||
limit_id: Some("codex".to_string()),
|
||||
limit_name: Some("codex".to_string()),
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 10.0,
|
||||
window_minutes: Some(60),
|
||||
resets_at: Some(100),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: Some(crate::protocol::CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("50".to_string()),
|
||||
}),
|
||||
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
||||
});
|
||||
|
||||
state.set_rate_limits(RateLimitSnapshot {
|
||||
limit_id: Some("codex_other".to_string()),
|
||||
limit_name: Some("codex_other".to_string()),
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 30.0,
|
||||
window_minutes: Some(120),
|
||||
resets_at: Some(200),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
state.latest_rate_limits,
|
||||
Some(RateLimitSnapshot {
|
||||
limit_id: Some("codex_other".to_string()),
|
||||
limit_name: Some("codex_other".to_string()),
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 30.0,
|
||||
window_minutes: Some(120),
|
||||
resets_at: Some(200),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: Some(crate::protocol::CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("50".to_string()),
|
||||
}),
|
||||
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1509,6 +1509,8 @@ async fn token_count_includes_rate_limits_snapshot() {
|
|||
json!({
|
||||
"info": null,
|
||||
"rate_limits": {
|
||||
"limit_id": "codex",
|
||||
"limit_name": null,
|
||||
"primary": {
|
||||
"used_percent": 12.5,
|
||||
"window_minutes": 10,
|
||||
|
|
@ -1558,6 +1560,8 @@ async fn token_count_includes_rate_limits_snapshot() {
|
|||
"model_context_window": 258400
|
||||
},
|
||||
"rate_limits": {
|
||||
"limit_id": "codex",
|
||||
"limit_name": null,
|
||||
"primary": {
|
||||
"used_percent": 12.5,
|
||||
"window_minutes": 10,
|
||||
|
|
@ -1630,6 +1634,8 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
|
|||
let codex = codex_fixture.codex.clone();
|
||||
|
||||
let expected_limits = json!({
|
||||
"limit_id": "codex",
|
||||
"limit_name": null,
|
||||
"primary": {
|
||||
"used_percent": 100.0,
|
||||
"window_minutes": 15,
|
||||
|
|
|
|||
|
|
@ -486,6 +486,8 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() {
|
|||
json!({
|
||||
"info": null,
|
||||
"rate_limits": {
|
||||
"limit_id": "codex",
|
||||
"limit_name": null,
|
||||
"primary": {
|
||||
"used_percent": 100.0,
|
||||
"window_minutes": 15,
|
||||
|
|
|
|||
|
|
@ -1251,6 +1251,8 @@ pub struct TokenCountEvent {
|
|||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct RateLimitSnapshot {
|
||||
pub limit_id: Option<String>,
|
||||
pub limit_name: Option<String>,
|
||||
pub primary: Option<RateLimitWindow>,
|
||||
pub secondary: Option<RateLimitWindow>,
|
||||
pub credits: Option<CreditsSnapshot>,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
//! the final answer. During streaming we hide the status row to avoid duplicate
|
||||
//! progress indicators; once commentary completes and stream queues drain, we
|
||||
//! re-show it so users still see turn-in-progress state between output bursts.
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
|
|
@ -41,6 +42,7 @@ use crate::bottom_pane::StatusLineSetupView;
|
|||
use crate::status::RateLimitWindowDisplay;
|
||||
use crate::status::format_directory_display;
|
||||
use crate::status::format_tokens_compact;
|
||||
use crate::status::rate_limit_snapshot_display_for_limit;
|
||||
use crate::text_formatting::proper_join;
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
|
|
@ -511,7 +513,7 @@ pub(crate) struct ChatWidget {
|
|||
session_header: SessionHeader,
|
||||
initial_user_message: Option<UserMessage>,
|
||||
token_info: Option<TokenUsageInfo>,
|
||||
rate_limit_snapshot: Option<RateLimitSnapshotDisplay>,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap<String, RateLimitSnapshotDisplay>,
|
||||
plan_type: Option<PlanType>,
|
||||
rate_limit_warnings: RateLimitWarningState,
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState,
|
||||
|
|
@ -1498,10 +1500,18 @@ impl ChatWidget {
|
|||
|
||||
pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
|
||||
if let Some(mut snapshot) = snapshot {
|
||||
let limit_id = snapshot
|
||||
.limit_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "codex".to_string());
|
||||
let limit_label = snapshot
|
||||
.limit_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| limit_id.clone());
|
||||
if snapshot.credits.is_none() {
|
||||
snapshot.credits = self
|
||||
.rate_limit_snapshot
|
||||
.as_ref()
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get(&limit_id)
|
||||
.and_then(|display| display.credits.as_ref())
|
||||
.map(|credits| CreditsSnapshot {
|
||||
has_credits: credits.has_credits,
|
||||
|
|
@ -1512,32 +1522,38 @@ impl ChatWidget {
|
|||
|
||||
self.plan_type = snapshot.plan_type.or(self.plan_type);
|
||||
|
||||
let warnings = self.rate_limit_warnings.take_warnings(
|
||||
snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|window| window.used_percent),
|
||||
snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.and_then(|window| window.window_minutes),
|
||||
snapshot.primary.as_ref().map(|window| window.used_percent),
|
||||
snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.and_then(|window| window.window_minutes),
|
||||
);
|
||||
let is_codex_limit = limit_id.eq_ignore_ascii_case("codex");
|
||||
let warnings = if is_codex_limit {
|
||||
self.rate_limit_warnings.take_warnings(
|
||||
snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|window| window.used_percent),
|
||||
snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.and_then(|window| window.window_minutes),
|
||||
snapshot.primary.as_ref().map(|window| window.used_percent),
|
||||
snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.and_then(|window| window.window_minutes),
|
||||
)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let high_usage = snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
|
||||
.unwrap_or(false)
|
||||
|| snapshot
|
||||
.primary
|
||||
let high_usage = is_codex_limit
|
||||
&& (snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
|
||||
.unwrap_or(false);
|
||||
.unwrap_or(false)
|
||||
|| snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD)
|
||||
.unwrap_or(false));
|
||||
|
||||
if high_usage
|
||||
&& !self.rate_limit_switch_prompt_hidden()
|
||||
|
|
@ -1550,8 +1566,10 @@ impl ChatWidget {
|
|||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending;
|
||||
}
|
||||
|
||||
let display = crate::status::rate_limit_snapshot_display(&snapshot, Local::now());
|
||||
self.rate_limit_snapshot = Some(display);
|
||||
let display =
|
||||
rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now());
|
||||
self.rate_limit_snapshots_by_limit_id
|
||||
.insert(limit_id, display);
|
||||
|
||||
if !warnings.is_empty() {
|
||||
for warning in warnings {
|
||||
|
|
@ -1560,7 +1578,7 @@ impl ChatWidget {
|
|||
self.request_redraw();
|
||||
}
|
||||
} else {
|
||||
self.rate_limit_snapshot = None;
|
||||
self.rate_limit_snapshots_by_limit_id.clear();
|
||||
}
|
||||
self.refresh_status_line();
|
||||
}
|
||||
|
|
@ -2608,7 +2626,7 @@ impl ChatWidget {
|
|||
session_header: SessionHeader::new(header_model),
|
||||
initial_user_message,
|
||||
token_info: None,
|
||||
rate_limit_snapshot: None,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
|
||||
plan_type: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
|
|
@ -2773,7 +2791,7 @@ impl ChatWidget {
|
|||
session_header: SessionHeader::new(header_model),
|
||||
initial_user_message,
|
||||
token_info: None,
|
||||
rate_limit_snapshot: None,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
|
||||
plan_type: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
|
|
@ -2927,7 +2945,7 @@ impl ChatWidget {
|
|||
session_header: SessionHeader::new(header_model),
|
||||
initial_user_message,
|
||||
token_info: None,
|
||||
rate_limit_snapshot: None,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
|
||||
plan_type: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
|
|
@ -4228,7 +4246,12 @@ impl ChatWidget {
|
|||
.unwrap_or(&default_usage);
|
||||
let collaboration_mode = self.collaboration_mode_label();
|
||||
let reasoning_effort_override = Some(self.effective_reasoning_effort());
|
||||
self.add_to_history(crate::status::new_status_output(
|
||||
let rate_limit_snapshots: Vec<RateLimitSnapshotDisplay> = self
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.values()
|
||||
.cloned()
|
||||
.collect();
|
||||
self.add_to_history(crate::status::new_status_output_with_rate_limits(
|
||||
&self.config,
|
||||
self.auth_manager.as_ref(),
|
||||
token_info,
|
||||
|
|
@ -4236,7 +4259,7 @@ impl ChatWidget {
|
|||
&self.thread_id,
|
||||
self.thread_name.clone(),
|
||||
self.forked_from,
|
||||
self.rate_limit_snapshot.as_ref(),
|
||||
rate_limit_snapshots.as_slice(),
|
||||
self.plan_type,
|
||||
Local::now(),
|
||||
self.model_display_name(),
|
||||
|
|
@ -4382,8 +4405,8 @@ impl ChatWidget {
|
|||
.map(|used| format!("{used}% used")),
|
||||
StatusLineItem::FiveHourLimit => {
|
||||
let window = self
|
||||
.rate_limit_snapshot
|
||||
.as_ref()
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.and_then(|s| s.primary.as_ref());
|
||||
let label = window
|
||||
.and_then(|window| window.window_minutes)
|
||||
|
|
@ -4393,8 +4416,8 @@ impl ChatWidget {
|
|||
}
|
||||
StatusLineItem::WeeklyLimit => {
|
||||
let window = self
|
||||
.rate_limit_snapshot
|
||||
.as_ref()
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.and_then(|s| s.secondary.as_ref());
|
||||
let label = window
|
||||
.and_then(|window| window.window_minutes)
|
||||
|
|
@ -4578,9 +4601,10 @@ impl ChatWidget {
|
|||
loop {
|
||||
if let Some(auth) = auth_manager.auth().await
|
||||
&& auth.is_chatgpt_auth()
|
||||
&& let Some(snapshot) = fetch_rate_limits(base_url.clone(), auth).await
|
||||
{
|
||||
app_event_tx.send(AppEvent::RateLimitSnapshotFetched(snapshot));
|
||||
for snapshot in fetch_rate_limits(base_url.clone(), auth).await {
|
||||
app_event_tx.send(AppEvent::RateLimitSnapshotFetched(snapshot));
|
||||
}
|
||||
}
|
||||
interval.tick().await;
|
||||
}
|
||||
|
|
@ -7146,18 +7170,18 @@ fn extract_first_bold(s: &str) -> Option<String> {
|
|||
None
|
||||
}
|
||||
|
||||
async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option<RateLimitSnapshot> {
|
||||
async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Vec<RateLimitSnapshot> {
|
||||
match BackendClient::from_auth(base_url, &auth) {
|
||||
Ok(client) => match client.get_rate_limits().await {
|
||||
Ok(snapshot) => Some(snapshot),
|
||||
Ok(client) => match client.get_rate_limits_many().await {
|
||||
Ok(snapshots) => snapshots,
|
||||
Err(err) => {
|
||||
debug!(error = ?err, "failed to fetch rate limits from /usage");
|
||||
None
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
debug!(error = ?err, "failed to construct backend client for rate limits");
|
||||
None
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ use insta::assert_snapshot;
|
|||
use pretty_assertions::assert_eq;
|
||||
#[cfg(target_os = "windows")]
|
||||
use serial_test::serial;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
|
|
@ -125,6 +126,8 @@ fn invalid_value(candidate: impl Into<String>, allowed: impl Into<String>) -> Co
|
|||
|
||||
fn snapshot(percent: f64) -> RateLimitSnapshot {
|
||||
RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: percent,
|
||||
window_minutes: Some(60),
|
||||
|
|
@ -1064,7 +1067,7 @@ async fn make_chatwidget_manual(
|
|||
session_header: SessionHeader::new(resolved_model.clone()),
|
||||
initial_user_message: None,
|
||||
token_info: None,
|
||||
rate_limit_snapshot: None,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
|
||||
plan_type: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
|
|
@ -1280,6 +1283,8 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
|||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
|
|
@ -1290,13 +1295,15 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
|||
plan_type: None,
|
||||
}));
|
||||
let initial_balance = chat
|
||||
.rate_limit_snapshot
|
||||
.as_ref()
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.and_then(|snapshot| snapshot.credits.as_ref())
|
||||
.and_then(|credits| credits.balance.as_deref());
|
||||
assert_eq!(initial_balance, Some("17.5"));
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 80.0,
|
||||
window_minutes: Some(60),
|
||||
|
|
@ -1308,8 +1315,8 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
|
|||
}));
|
||||
|
||||
let display = chat
|
||||
.rate_limit_snapshot
|
||||
.as_ref()
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.expect("rate limits should be cached");
|
||||
let credits = display
|
||||
.credits
|
||||
|
|
@ -1329,6 +1336,8 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
|||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 10.0,
|
||||
window_minutes: Some(60),
|
||||
|
|
@ -1345,6 +1354,8 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
|||
assert_eq!(chat.plan_type, Some(PlanType::Plus));
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 25.0,
|
||||
window_minutes: Some(30),
|
||||
|
|
@ -1361,6 +1372,8 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
|||
assert_eq!(chat.plan_type, Some(PlanType::Pro));
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 30.0,
|
||||
window_minutes: Some(60),
|
||||
|
|
@ -1377,6 +1390,61 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
|
|||
assert_eq!(chat.plan_type, Some(PlanType::Pro));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: Some("codex".to_string()),
|
||||
limit_name: Some("codex".to_string()),
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 20.0,
|
||||
window_minutes: Some(300),
|
||||
resets_at: Some(100),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("5.00".to_string()),
|
||||
}),
|
||||
plan_type: Some(PlanType::Pro),
|
||||
}));
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: Some("codex_other".to_string()),
|
||||
limit_name: Some("codex_other".to_string()),
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 90.0,
|
||||
window_minutes: Some(60),
|
||||
resets_at: Some(200),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: Some(PlanType::Pro),
|
||||
}));
|
||||
|
||||
let codex = chat
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.expect("codex snapshot should exist");
|
||||
let other = chat
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex_other")
|
||||
.expect("codex_other snapshot should exist");
|
||||
|
||||
assert_eq!(codex.primary.as_ref().map(|w| w.used_percent), Some(20.0));
|
||||
assert_eq!(
|
||||
codex
|
||||
.credits
|
||||
.as_ref()
|
||||
.and_then(|credits| credits.balance.as_deref()),
|
||||
Some("5.00")
|
||||
);
|
||||
assert_eq!(other.primary.as_ref().map(|w| w.used_percent), Some(90.0));
|
||||
assert!(other.credits.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() {
|
||||
let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)).await;
|
||||
|
|
@ -1391,6 +1459,31 @@ async fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() {
|
|||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_switch_prompt_skips_non_codex_limit() {
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.auth_manager = AuthManager::from_auth_for_testing(auth);
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
|
||||
limit_id: Some("codex_other".to_string()),
|
||||
limit_name: Some("codex_other".to_string()),
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 95.0,
|
||||
window_minutes: Some(60),
|
||||
resets_at: None,
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
}));
|
||||
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Idle
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_switch_prompt_shows_once_per_session() {
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ use super::rate_limits::StatusRateLimitData;
|
|||
use super::rate_limits::StatusRateLimitRow;
|
||||
use super::rate_limits::StatusRateLimitValue;
|
||||
use super::rate_limits::compose_rate_limit_data;
|
||||
use super::rate_limits::compose_rate_limit_data_many;
|
||||
use super::rate_limits::format_status_limit_summary;
|
||||
use super::rate_limits::render_status_limit_progress_bar;
|
||||
use crate::wrapping::RtOptions;
|
||||
|
|
@ -75,6 +76,7 @@ struct StatusHistoryCell {
|
|||
rate_limits: StatusRateLimitData,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new_status_output(
|
||||
config: &Config,
|
||||
|
|
@ -90,6 +92,40 @@ pub(crate) fn new_status_output(
|
|||
model_name: &str,
|
||||
collaboration_mode: Option<&str>,
|
||||
reasoning_effort_override: Option<Option<ReasoningEffort>>,
|
||||
) -> CompositeHistoryCell {
|
||||
let snapshots = rate_limits.map(std::slice::from_ref).unwrap_or_default();
|
||||
new_status_output_with_rate_limits(
|
||||
config,
|
||||
auth_manager,
|
||||
token_info,
|
||||
total_usage,
|
||||
session_id,
|
||||
thread_name,
|
||||
forked_from,
|
||||
snapshots,
|
||||
plan_type,
|
||||
now,
|
||||
model_name,
|
||||
collaboration_mode,
|
||||
reasoning_effort_override,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new_status_output_with_rate_limits(
|
||||
config: &Config,
|
||||
auth_manager: &AuthManager,
|
||||
token_info: Option<&TokenUsageInfo>,
|
||||
total_usage: &TokenUsage,
|
||||
session_id: &Option<ThreadId>,
|
||||
thread_name: Option<String>,
|
||||
forked_from: Option<ThreadId>,
|
||||
rate_limits: &[RateLimitSnapshotDisplay],
|
||||
plan_type: Option<PlanType>,
|
||||
now: DateTime<Local>,
|
||||
model_name: &str,
|
||||
collaboration_mode: Option<&str>,
|
||||
reasoning_effort_override: Option<Option<ReasoningEffort>>,
|
||||
) -> CompositeHistoryCell {
|
||||
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
|
||||
let card = StatusHistoryCell::new(
|
||||
|
|
@ -121,7 +157,7 @@ impl StatusHistoryCell {
|
|||
session_id: &Option<ThreadId>,
|
||||
thread_name: Option<String>,
|
||||
forked_from: Option<ThreadId>,
|
||||
rate_limits: Option<&RateLimitSnapshotDisplay>,
|
||||
rate_limits: &[RateLimitSnapshotDisplay],
|
||||
plan_type: Option<PlanType>,
|
||||
now: DateTime<Local>,
|
||||
model_name: &str,
|
||||
|
|
@ -189,7 +225,11 @@ impl StatusHistoryCell {
|
|||
output: total_usage.output_tokens,
|
||||
context_window,
|
||||
};
|
||||
let rate_limits = compose_rate_limit_data(rate_limits, now);
|
||||
let rate_limits = if rate_limits.len() <= 1 {
|
||||
compose_rate_limit_data(rate_limits.first(), now)
|
||||
} else {
|
||||
compose_rate_limit_data_many(rate_limits, now)
|
||||
};
|
||||
|
||||
Self {
|
||||
model_name,
|
||||
|
|
|
|||
|
|
@ -12,12 +12,16 @@ mod format;
|
|||
mod helpers;
|
||||
mod rate_limits;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use card::new_status_output;
|
||||
pub(crate) use card::new_status_output_with_rate_limits;
|
||||
pub(crate) use helpers::format_directory_display;
|
||||
pub(crate) use helpers::format_tokens_compact;
|
||||
pub(crate) use rate_limits::RateLimitSnapshotDisplay;
|
||||
pub(crate) use rate_limits::RateLimitWindowDisplay;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rate_limits::rate_limit_snapshot_display;
|
||||
pub(crate) use rate_limits::rate_limit_snapshot_display_for_limit;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -86,6 +86,8 @@ impl RateLimitWindowDisplay {
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RateLimitSnapshotDisplay {
|
||||
/// Canonical limit identifier (for example: `codex` or `codex_other`).
|
||||
pub limit_name: String,
|
||||
/// Local timestamp representing when this display snapshot was captured.
|
||||
pub captured_at: DateTime<Local>,
|
||||
/// Primary usage window (typically short duration).
|
||||
|
|
@ -111,11 +113,21 @@ pub(crate) struct CreditsSnapshotDisplay {
|
|||
///
|
||||
/// Pass the timestamp from the same observation point as `snapshot`; supplying a significantly
|
||||
/// older or newer `captured_at` can produce misleading reset labels and stale classification.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn rate_limit_snapshot_display(
|
||||
snapshot: &RateLimitSnapshot,
|
||||
captured_at: DateTime<Local>,
|
||||
) -> RateLimitSnapshotDisplay {
|
||||
rate_limit_snapshot_display_for_limit(snapshot, "codex".to_string(), captured_at)
|
||||
}
|
||||
|
||||
pub(crate) fn rate_limit_snapshot_display_for_limit(
|
||||
snapshot: &RateLimitSnapshot,
|
||||
limit_name: String,
|
||||
captured_at: DateTime<Local>,
|
||||
) -> RateLimitSnapshotDisplay {
|
||||
RateLimitSnapshotDisplay {
|
||||
limit_name,
|
||||
captured_at,
|
||||
primary: snapshot
|
||||
.primary
|
||||
|
|
@ -148,60 +160,123 @@ pub(crate) fn compose_rate_limit_data(
|
|||
now: DateTime<Local>,
|
||||
) -> StatusRateLimitData {
|
||||
match snapshot {
|
||||
Some(snapshot) => {
|
||||
let mut rows = Vec::with_capacity(3);
|
||||
|
||||
if let Some(primary) = snapshot.primary.as_ref() {
|
||||
let label: String = primary
|
||||
.window_minutes
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "5h".to_string());
|
||||
let label = capitalize_first(&label);
|
||||
rows.push(StatusRateLimitRow {
|
||||
label: format!("{label} limit"),
|
||||
value: StatusRateLimitValue::Window {
|
||||
percent_used: primary.used_percent,
|
||||
resets_at: primary.resets_at.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(secondary) = snapshot.secondary.as_ref() {
|
||||
let label: String = secondary
|
||||
.window_minutes
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "weekly".to_string());
|
||||
let label = capitalize_first(&label);
|
||||
rows.push(StatusRateLimitRow {
|
||||
label: format!("{label} limit"),
|
||||
value: StatusRateLimitValue::Window {
|
||||
percent_used: secondary.used_percent,
|
||||
resets_at: secondary.resets_at.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(credits) = snapshot.credits.as_ref()
|
||||
&& let Some(row) = credit_status_row(credits)
|
||||
{
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
let is_stale = now.signed_duration_since(snapshot.captured_at)
|
||||
> ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES);
|
||||
|
||||
if rows.is_empty() {
|
||||
StatusRateLimitData::Available(vec![])
|
||||
} else if is_stale {
|
||||
StatusRateLimitData::Stale(rows)
|
||||
} else {
|
||||
StatusRateLimitData::Available(rows)
|
||||
}
|
||||
}
|
||||
Some(snapshot) => compose_rate_limit_data_many(std::slice::from_ref(snapshot), now),
|
||||
None => StatusRateLimitData::Missing,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn compose_rate_limit_data_many(
|
||||
snapshots: &[RateLimitSnapshotDisplay],
|
||||
now: DateTime<Local>,
|
||||
) -> StatusRateLimitData {
|
||||
if snapshots.is_empty() {
|
||||
return StatusRateLimitData::Missing;
|
||||
}
|
||||
|
||||
let mut rows = Vec::with_capacity(snapshots.len().saturating_mul(3));
|
||||
let mut stale = false;
|
||||
|
||||
for snapshot in snapshots {
|
||||
stale |= now.signed_duration_since(snapshot.captured_at)
|
||||
> ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES);
|
||||
|
||||
let limit_bucket_label = snapshot.limit_name.clone();
|
||||
let show_limit_prefix = !limit_bucket_label.eq_ignore_ascii_case("codex");
|
||||
let primary_label = snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.map(|window| {
|
||||
window
|
||||
.window_minutes
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "5h".to_string())
|
||||
})
|
||||
.map(|label| capitalize_first(&label));
|
||||
let secondary_label = snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|window| {
|
||||
window
|
||||
.window_minutes
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "weekly".to_string())
|
||||
})
|
||||
.map(|label| capitalize_first(&label));
|
||||
let window_count =
|
||||
usize::from(snapshot.primary.is_some()) + usize::from(snapshot.secondary.is_some());
|
||||
let combine_non_codex_single_limit = show_limit_prefix && window_count == 1;
|
||||
|
||||
if show_limit_prefix && !combine_non_codex_single_limit {
|
||||
rows.push(StatusRateLimitRow {
|
||||
label: format!("{limit_bucket_label} limit"),
|
||||
value: StatusRateLimitValue::Text(String::new()),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(primary) = snapshot.primary.as_ref() {
|
||||
let label = if combine_non_codex_single_limit {
|
||||
format!(
|
||||
"{} {} limit",
|
||||
limit_bucket_label,
|
||||
primary_label.clone().unwrap_or_else(|| "5h".to_string())
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} limit",
|
||||
primary_label.clone().unwrap_or_else(|| "5h".to_string())
|
||||
)
|
||||
};
|
||||
rows.push(StatusRateLimitRow {
|
||||
label,
|
||||
value: StatusRateLimitValue::Window {
|
||||
percent_used: primary.used_percent,
|
||||
resets_at: primary.resets_at.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(secondary) = snapshot.secondary.as_ref() {
|
||||
let label = if combine_non_codex_single_limit {
|
||||
format!(
|
||||
"{} {} limit",
|
||||
limit_bucket_label,
|
||||
secondary_label
|
||||
.clone()
|
||||
.unwrap_or_else(|| "weekly".to_string())
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} limit",
|
||||
secondary_label
|
||||
.clone()
|
||||
.unwrap_or_else(|| "weekly".to_string())
|
||||
)
|
||||
};
|
||||
rows.push(StatusRateLimitRow {
|
||||
label,
|
||||
value: StatusRateLimitValue::Window {
|
||||
percent_used: secondary.used_percent,
|
||||
resets_at: secondary.resets_at.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(credits) = snapshot.credits.as_ref()
|
||||
&& let Some(row) = credit_status_row(credits)
|
||||
{
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
if rows.is_empty() {
|
||||
StatusRateLimitData::Available(vec![])
|
||||
} else if stale {
|
||||
StatusRateLimitData::Stale(rows)
|
||||
} else {
|
||||
StatusRateLimitData::Available(rows)
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a fixed-width progress bar from remaining percentage.
|
||||
///
|
||||
/// This function expects a remaining value in the `0..=100` range and clamps out-of-range input.
|
||||
|
|
@ -266,3 +341,100 @@ fn format_credit_balance(raw: &str) -> Option<String> {
|
|||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::CreditsSnapshotDisplay;
|
||||
use super::RateLimitSnapshotDisplay;
|
||||
use super::RateLimitWindowDisplay;
|
||||
use super::StatusRateLimitData;
|
||||
use super::compose_rate_limit_data_many;
|
||||
use chrono::Local;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn window(used_percent: f64) -> RateLimitWindowDisplay {
|
||||
RateLimitWindowDisplay {
|
||||
used_percent,
|
||||
resets_at: Some("soon".to_string()),
|
||||
window_minutes: Some(300),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_codex_single_limit_renders_combined_row() {
|
||||
let now = Local::now();
|
||||
let codex = RateLimitSnapshotDisplay {
|
||||
limit_name: "codex".to_string(),
|
||||
captured_at: now,
|
||||
primary: Some(window(10.0)),
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshotDisplay {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("25".to_string()),
|
||||
}),
|
||||
};
|
||||
let other = RateLimitSnapshotDisplay {
|
||||
limit_name: "codex-other".to_string(),
|
||||
captured_at: now,
|
||||
primary: Some(window(20.0)),
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshotDisplay {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("99".to_string()),
|
||||
}),
|
||||
};
|
||||
|
||||
let rows = match compose_rate_limit_data_many(&[codex, other], now) {
|
||||
StatusRateLimitData::Available(rows) => rows,
|
||||
other => panic!("unexpected status: {other:?}"),
|
||||
};
|
||||
|
||||
let labels: Vec<String> = rows.iter().map(|row| row.label.clone()).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec![
|
||||
"5h limit".to_string(),
|
||||
"Credits".to_string(),
|
||||
"codex-other 5h limit".to_string(),
|
||||
"Credits".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(rows.iter().filter(|row| row.label == "Credits").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_codex_multi_limit_keeps_group_row() {
|
||||
let now = Local::now();
|
||||
let other = RateLimitSnapshotDisplay {
|
||||
limit_name: "codex-other".to_string(),
|
||||
captured_at: now,
|
||||
primary: Some(RateLimitWindowDisplay {
|
||||
used_percent: 20.0,
|
||||
resets_at: Some("soon".to_string()),
|
||||
window_minutes: Some(60),
|
||||
}),
|
||||
secondary: Some(RateLimitWindowDisplay {
|
||||
used_percent: 40.0,
|
||||
resets_at: Some("later".to_string()),
|
||||
window_minutes: None,
|
||||
}),
|
||||
credits: None,
|
||||
};
|
||||
|
||||
let rows = match compose_rate_limit_data_many(&[other], now) {
|
||||
StatusRateLimitData::Available(rows) => rows,
|
||||
other => panic!("unexpected status: {other:?}"),
|
||||
};
|
||||
let labels: Vec<String> = rows.iter().map(|row| row.label.clone()).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec![
|
||||
"codex-other limit".to_string(),
|
||||
"1h limit".to_string(),
|
||||
"Weekly limit".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,8 @@ async fn status_snapshot_includes_reasoning_details() {
|
|||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 72.5,
|
||||
window_minutes: Some(300),
|
||||
|
|
@ -242,6 +244,8 @@ async fn status_snapshot_includes_monthly_limit() {
|
|||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 12.0,
|
||||
window_minutes: Some(43_200),
|
||||
|
|
@ -291,6 +295,8 @@ async fn status_snapshot_shows_unlimited_credits() {
|
|||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
|
|
@ -338,6 +344,8 @@ async fn status_snapshot_shows_positive_credits() {
|
|||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
|
|
@ -385,6 +393,8 @@ async fn status_snapshot_hides_zero_credits() {
|
|||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
|
|
@ -430,6 +440,8 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
|
|||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
|
|
@ -533,6 +545,8 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
|
|||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 72.5,
|
||||
window_minutes: Some(300),
|
||||
|
|
@ -642,6 +656,8 @@ async fn status_snapshot_includes_credits_and_limits() {
|
|||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 45.0,
|
||||
window_minutes: Some(300),
|
||||
|
|
@ -705,6 +721,8 @@ async fn status_snapshot_shows_empty_limits_message() {
|
|||
};
|
||||
|
||||
let snapshot = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: None,
|
||||
|
|
@ -764,6 +782,8 @@ async fn status_snapshot_shows_stale_limits_message() {
|
|||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 72.5,
|
||||
window_minutes: Some(300),
|
||||
|
|
@ -828,6 +848,8 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
|
|||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 60.0,
|
||||
window_minutes: Some(300),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue