mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
storing credits (#6858)
Expand the rate-limit cache/TUI: store credit snapshots alongside primary and secondary windows, render “Credits” when the backend reports they exist (unlimited vs rounded integer balances)
This commit is contained in:
committed by
GitHub
Unverified
parent
b3d320433f
commit
72af589398
@@ -11,6 +11,7 @@ use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
|
||||
use codex_protocol::items::TurnItem as CoreTurnItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand;
|
||||
use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
|
||||
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
|
||||
use codex_protocol::user_input::UserInput as CoreUserInput;
|
||||
@@ -994,6 +995,7 @@ pub struct AccountRateLimitsUpdatedNotification {
|
||||
pub struct RateLimitSnapshot {
|
||||
pub primary: Option<RateLimitWindow>,
|
||||
pub secondary: Option<RateLimitWindow>,
|
||||
pub credits: Option<CreditsSnapshot>,
|
||||
}
|
||||
|
||||
impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
|
||||
@@ -1001,6 +1003,7 @@ impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
|
||||
Self {
|
||||
primary: value.primary.map(RateLimitWindow::from),
|
||||
secondary: value.secondary.map(RateLimitWindow::from),
|
||||
credits: value.credits.map(CreditsSnapshot::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1024,6 +1027,25 @@ impl From<CoreRateLimitWindow> for RateLimitWindow {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct CreditsSnapshot {
|
||||
pub has_credits: bool,
|
||||
pub unlimited: bool,
|
||||
pub balance: Option<String>,
|
||||
}
|
||||
|
||||
impl From<CoreCreditsSnapshot> for CreditsSnapshot {
|
||||
fn from(value: CoreCreditsSnapshot) -> Self {
|
||||
Self {
|
||||
has_credits: value.has_credits,
|
||||
unlimited: value.unlimited,
|
||||
balance: value.balance,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -229,6 +229,7 @@ mod tests {
|
||||
resets_at: Some(123),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -243,7 +244,8 @@ mod tests {
|
||||
"windowDurationMins": 15,
|
||||
"resetsAt": 123
|
||||
},
|
||||
"secondary": null
|
||||
"secondary": null,
|
||||
"credits": null
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -152,6 +152,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
window_duration_mins: Some(1440),
|
||||
resets_at: Some(secondary_reset_timestamp),
|
||||
}),
|
||||
credits: None,
|
||||
},
|
||||
};
|
||||
assert_eq!(received, expected);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::types::CodeTaskDetailsResponse;
|
||||
use crate::types::CreditStatusDetails;
|
||||
use crate::types::PaginatedListTaskListItem;
|
||||
use crate::types::RateLimitStatusPayload;
|
||||
use crate::types::RateLimitWindowSnapshot;
|
||||
@@ -6,6 +7,7 @@ use crate::types::TurnAttemptsSiblingTurnsResponse;
|
||||
use anyhow::Result;
|
||||
use codex_core::auth::CodexAuth;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
use codex_protocol::protocol::CreditsSnapshot;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
@@ -272,19 +274,23 @@ impl Client {
|
||||
|
||||
// rate limit helpers
|
||||
fn rate_limit_snapshot_from_payload(payload: RateLimitStatusPayload) -> RateLimitSnapshot {
|
||||
let Some(details) = payload
|
||||
let rate_limit_details = payload
|
||||
.rate_limit
|
||||
.and_then(|inner| inner.map(|boxed| *boxed))
|
||||
else {
|
||||
return RateLimitSnapshot {
|
||||
primary: None,
|
||||
secondary: None,
|
||||
};
|
||||
.and_then(|inner| inner.map(|boxed| *boxed));
|
||||
|
||||
let (primary, secondary) = if let Some(details) = rate_limit_details {
|
||||
(
|
||||
Self::map_rate_limit_window(details.primary_window),
|
||||
Self::map_rate_limit_window(details.secondary_window),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
RateLimitSnapshot {
|
||||
primary: Self::map_rate_limit_window(details.primary_window),
|
||||
secondary: Self::map_rate_limit_window(details.secondary_window),
|
||||
primary,
|
||||
secondary,
|
||||
credits: Self::map_credits(payload.credits),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +312,19 @@ impl Client {
|
||||
})
|
||||
}
|
||||
|
||||
fn map_credits(credits: Option<Option<Box<CreditStatusDetails>>>) -> Option<CreditsSnapshot> {
|
||||
let details = match credits {
|
||||
Some(Some(details)) => *details,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(CreditsSnapshot {
|
||||
has_credits: details.has_credits,
|
||||
unlimited: details.unlimited,
|
||||
balance: details.balance.and_then(|inner| inner),
|
||||
})
|
||||
}
|
||||
|
||||
fn window_minutes_from_seconds(seconds: i32) -> Option<i64> {
|
||||
if seconds <= 0 {
|
||||
return None;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub use codex_backend_openapi_models::models::CreditStatusDetails;
|
||||
pub use codex_backend_openapi_models::models::PaginatedListTaskListItem;
|
||||
pub use codex_backend_openapi_models::models::PlanType;
|
||||
pub use codex_backend_openapi_models::models::RateLimitStatusDetails;
|
||||
|
||||
@@ -56,6 +56,7 @@ use crate::model_family::ModelFamily;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::model_provider_info::WireApi;
|
||||
use crate::openai_model_info::get_model_info;
|
||||
use crate::protocol::CreditsSnapshot;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::RateLimitWindow;
|
||||
use crate::protocol::TokenUsage;
|
||||
@@ -726,7 +727,13 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshot> {
|
||||
"x-codex-secondary-reset-at",
|
||||
);
|
||||
|
||||
Some(RateLimitSnapshot { primary, secondary })
|
||||
let credits = parse_credits_snapshot(headers);
|
||||
|
||||
Some(RateLimitSnapshot {
|
||||
primary,
|
||||
secondary,
|
||||
credits,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_rate_limit_window(
|
||||
@@ -753,6 +760,20 @@ fn parse_rate_limit_window(
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_credits_snapshot(headers: &HeaderMap) -> Option<CreditsSnapshot> {
|
||||
let has_credits = parse_header_bool(headers, "x-codex-credits-has-credits")?;
|
||||
let unlimited = parse_header_bool(headers, "x-codex-credits-unlimited")?;
|
||||
let balance = parse_header_str(headers, "x-codex-credits-balance")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(std::string::ToString::to_string);
|
||||
Some(CreditsSnapshot {
|
||||
has_credits,
|
||||
unlimited,
|
||||
balance,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_header_f64(headers: &HeaderMap, name: &str) -> Option<f64> {
|
||||
parse_header_str(headers, name)?
|
||||
.parse::<f64>()
|
||||
@@ -764,6 +785,17 @@ fn parse_header_i64(headers: &HeaderMap, name: &str) -> Option<i64> {
|
||||
parse_header_str(headers, name)?.parse::<i64>().ok()
|
||||
}
|
||||
|
||||
fn parse_header_bool(headers: &HeaderMap, name: &str) -> Option<bool> {
|
||||
let raw = parse_header_str(headers, name)?;
|
||||
if raw.eq_ignore_ascii_case("true") || raw == "1" {
|
||||
Some(true)
|
||||
} else if raw.eq_ignore_ascii_case("false") || raw == "0" {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
headers.get(name)?.to_str().ok()
|
||||
}
|
||||
|
||||
@@ -499,6 +499,7 @@ mod tests {
|
||||
window_minutes: Some(120),
|
||||
resets_at: Some(secondary_reset_at),
|
||||
}),
|
||||
credits: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1121,7 +1121,8 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
"used_percent": 40.0,
|
||||
"window_minutes": 60,
|
||||
"resets_at": 1704074400
|
||||
}
|
||||
},
|
||||
"credits": null
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -1168,7 +1169,8 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
"used_percent": 40.0,
|
||||
"window_minutes": 60,
|
||||
"resets_at": 1704074400
|
||||
}
|
||||
},
|
||||
"credits": null
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -1238,7 +1240,8 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
|
||||
"used_percent": 87.5,
|
||||
"window_minutes": 60,
|
||||
"resets_at": null
|
||||
}
|
||||
},
|
||||
"credits": null
|
||||
});
|
||||
|
||||
let submission_id = codex
|
||||
|
||||
@@ -790,6 +790,7 @@ pub struct TokenCountEvent {
|
||||
pub struct RateLimitSnapshot {
|
||||
pub primary: Option<RateLimitWindow>,
|
||||
pub secondary: Option<RateLimitWindow>,
|
||||
pub credits: Option<CreditsSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
|
||||
@@ -804,6 +805,13 @@ pub struct RateLimitWindow {
|
||||
pub resets_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct CreditsSnapshot {
|
||||
pub has_credits: bool,
|
||||
pub unlimited: bool,
|
||||
pub balance: Option<String>,
|
||||
}
|
||||
|
||||
// Includes prompts, tools and space to call compact.
|
||||
const BASELINE_TOKENS: i64 = 12000;
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ fn snapshot(percent: f64) -> RateLimitSnapshot {
|
||||
resets_at: None,
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ use super::helpers::format_tokens_compact;
|
||||
use super::rate_limits::RateLimitSnapshotDisplay;
|
||||
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::format_status_limit_summary;
|
||||
use super::rate_limits::render_status_limit_progress_bar;
|
||||
@@ -215,29 +216,44 @@ impl StatusHistoryCell {
|
||||
let mut lines = Vec::with_capacity(rows.len().saturating_mul(2));
|
||||
|
||||
for row in rows {
|
||||
let percent_remaining = (100.0 - row.percent_used).clamp(0.0, 100.0);
|
||||
let value_spans = vec![
|
||||
Span::from(render_status_limit_progress_bar(percent_remaining)),
|
||||
Span::from(" "),
|
||||
Span::from(format_status_limit_summary(percent_remaining)),
|
||||
];
|
||||
let base_spans = formatter.full_spans(row.label.as_str(), value_spans);
|
||||
let base_line = Line::from(base_spans.clone());
|
||||
match &row.value {
|
||||
StatusRateLimitValue::Window {
|
||||
percent_used,
|
||||
resets_at,
|
||||
} => {
|
||||
let percent_remaining = (100.0 - percent_used).clamp(0.0, 100.0);
|
||||
let value_spans = vec![
|
||||
Span::from(render_status_limit_progress_bar(percent_remaining)),
|
||||
Span::from(" "),
|
||||
Span::from(format_status_limit_summary(percent_remaining)),
|
||||
];
|
||||
let base_spans = formatter.full_spans(row.label.as_str(), value_spans);
|
||||
let base_line = Line::from(base_spans.clone());
|
||||
|
||||
if let Some(resets_at) = row.resets_at.as_ref() {
|
||||
let resets_span = Span::from(format!("(resets {resets_at})")).dim();
|
||||
let mut inline_spans = base_spans.clone();
|
||||
inline_spans.push(Span::from(" ").dim());
|
||||
inline_spans.push(resets_span.clone());
|
||||
if let Some(resets_at) = resets_at.as_ref() {
|
||||
let resets_span = Span::from(format!("(resets {resets_at})")).dim();
|
||||
let mut inline_spans = base_spans.clone();
|
||||
inline_spans.push(Span::from(" ").dim());
|
||||
inline_spans.push(resets_span.clone());
|
||||
|
||||
if line_display_width(&Line::from(inline_spans.clone())) <= available_inner_width {
|
||||
lines.push(Line::from(inline_spans));
|
||||
} else {
|
||||
lines.push(base_line);
|
||||
lines.push(formatter.continuation(vec![resets_span]));
|
||||
if line_display_width(&Line::from(inline_spans.clone()))
|
||||
<= available_inner_width
|
||||
{
|
||||
lines.push(Line::from(inline_spans));
|
||||
} else {
|
||||
lines.push(base_line);
|
||||
lines.push(formatter.continuation(vec![resets_span]));
|
||||
}
|
||||
} else {
|
||||
lines.push(base_line);
|
||||
}
|
||||
}
|
||||
StatusRateLimitValue::Text(text) => {
|
||||
let label = row.label.clone();
|
||||
let spans =
|
||||
formatter.full_spans(label.as_str(), vec![Span::from(text.clone())]);
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
} else {
|
||||
lines.push(base_line);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use chrono::DateTime;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use chrono::Local;
|
||||
use chrono::Utc;
|
||||
use codex_core::protocol::CreditsSnapshot as CoreCreditsSnapshot;
|
||||
use codex_core::protocol::RateLimitSnapshot;
|
||||
use codex_core::protocol::RateLimitWindow;
|
||||
|
||||
@@ -15,8 +16,16 @@ const STATUS_LIMIT_BAR_EMPTY: &str = "░";
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct StatusRateLimitRow {
|
||||
pub label: String,
|
||||
pub percent_used: f64,
|
||||
pub resets_at: Option<String>,
|
||||
pub value: StatusRateLimitValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum StatusRateLimitValue {
|
||||
Window {
|
||||
percent_used: f64,
|
||||
resets_at: Option<String>,
|
||||
},
|
||||
Text(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -56,6 +65,14 @@ pub(crate) struct RateLimitSnapshotDisplay {
|
||||
pub captured_at: DateTime<Local>,
|
||||
pub primary: Option<RateLimitWindowDisplay>,
|
||||
pub secondary: Option<RateLimitWindowDisplay>,
|
||||
pub credits: Option<CreditsSnapshotDisplay>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CreditsSnapshotDisplay {
|
||||
pub has_credits: bool,
|
||||
pub unlimited: bool,
|
||||
pub balance: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn rate_limit_snapshot_display(
|
||||
@@ -72,6 +89,17 @@ pub(crate) fn rate_limit_snapshot_display(
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
|
||||
credits: snapshot.credits.as_ref().map(CreditsSnapshotDisplay::from),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&CoreCreditsSnapshot> for CreditsSnapshotDisplay {
|
||||
fn from(value: &CoreCreditsSnapshot) -> Self {
|
||||
Self {
|
||||
has_credits: value.has_credits,
|
||||
unlimited: value.unlimited,
|
||||
balance: value.balance.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +109,7 @@ pub(crate) fn compose_rate_limit_data(
|
||||
) -> StatusRateLimitData {
|
||||
match snapshot {
|
||||
Some(snapshot) => {
|
||||
let mut rows = Vec::with_capacity(2);
|
||||
let mut rows = Vec::with_capacity(3);
|
||||
|
||||
if let Some(primary) = snapshot.primary.as_ref() {
|
||||
let label: String = primary
|
||||
@@ -91,8 +119,10 @@ pub(crate) fn compose_rate_limit_data(
|
||||
let label = capitalize_first(&label);
|
||||
rows.push(StatusRateLimitRow {
|
||||
label: format!("{label} limit"),
|
||||
percent_used: primary.used_percent,
|
||||
resets_at: primary.resets_at.clone(),
|
||||
value: StatusRateLimitValue::Window {
|
||||
percent_used: primary.used_percent,
|
||||
resets_at: primary.resets_at.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,11 +134,19 @@ pub(crate) fn compose_rate_limit_data(
|
||||
let label = capitalize_first(&label);
|
||||
rows.push(StatusRateLimitRow {
|
||||
label: format!("{label} limit"),
|
||||
percent_used: secondary.used_percent,
|
||||
resets_at: secondary.resets_at.clone(),
|
||||
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);
|
||||
|
||||
@@ -140,6 +178,50 @@ 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.
|
||||
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)?;
|
||||
Some(StatusRateLimitRow {
|
||||
label: "Credits".to_string(),
|
||||
value: StatusRateLimitValue::Text(format!("{display_balance} credits")),
|
||||
})
|
||||
}
|
||||
|
||||
fn format_credit_balance(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(int_value) = trimmed.parse::<i64>()
|
||||
&& int_value > 0
|
||||
{
|
||||
return Some(int_value.to_string());
|
||||
}
|
||||
|
||||
if let Ok(value) = trimmed.parse::<f64>()
|
||||
&& value > 0.0
|
||||
{
|
||||
let rounded = value.round() as i64;
|
||||
return Some(rounded.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn capitalize_first(label: &str) -> String {
|
||||
let mut chars = label.chars();
|
||||
match chars.next() {
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
---
|
||||
source: tui/src/status/tests.rs
|
||||
expression: sanitized
|
||||
---
|
||||
/status
|
||||
|
||||
╭─────────────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │
|
||||
│ information on rate limits and credits │
|
||||
│ │
|
||||
│ Model: gpt-5.1-codex (reasoning none, summaries auto) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: read-only │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 1.05K total (700 input + 350 output) │
|
||||
│ Context window: 100% left (1.45K used / 272K) │
|
||||
│ 5h limit: [████████░░░░░░░░░░░░] 40% left (resets 11:32) │
|
||||
│ Weekly limit: [█████████████░░░░░░░] 65% left (resets 11:52) │
|
||||
│ Warning: limits may be stale - start new turn to refresh. │
|
||||
╰─────────────────────────────────────────────────────────────────────╯
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
---
|
||||
source: tui/src/status/tests.rs
|
||||
expression: sanitized
|
||||
---
|
||||
/status
|
||||
|
||||
╭───────────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │
|
||||
│ information on rate limits and credits │
|
||||
│ │
|
||||
│ Model: gpt-5.1-codex (reasoning none, summaries auto) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: read-only │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 2K total (1.4K input + 600 output) │
|
||||
│ 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 │
|
||||
╰───────────────────────────────────────────────────────────────────╯
|
||||
@@ -8,6 +8,7 @@ use codex_core::AuthManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_core::protocol::CreditsSnapshot;
|
||||
use codex_core::protocol::RateLimitSnapshot;
|
||||
use codex_core::protocol::RateLimitWindow;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
@@ -118,6 +119,7 @@ fn status_snapshot_includes_reasoning_details() {
|
||||
window_minutes: Some(10080),
|
||||
resets_at: Some(reset_at_from(&captured_at, 1_200)),
|
||||
}),
|
||||
credits: None,
|
||||
};
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
|
||||
@@ -168,6 +170,7 @@ fn status_snapshot_includes_monthly_limit() {
|
||||
resets_at: Some(reset_at_from(&captured_at, 86_400)),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
};
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
|
||||
@@ -190,6 +193,154 @@ fn status_snapshot_includes_monthly_limit() {
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_snapshot_shows_unlimited_credits() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let config = test_config(&temp_home);
|
||||
let auth_manager = test_auth_manager(&config);
|
||||
let usage = TokenUsage::default();
|
||||
let captured_at = chrono::Local
|
||||
.with_ymd_and_hms(2024, 2, 3, 4, 5, 6)
|
||||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: true,
|
||||
balance: None,
|
||||
}),
|
||||
};
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
let composite = new_status_output(
|
||||
&config,
|
||||
&auth_manager,
|
||||
&usage,
|
||||
Some(&usage),
|
||||
&None,
|
||||
Some(&rate_display),
|
||||
captured_at,
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(120));
|
||||
assert!(
|
||||
rendered
|
||||
.iter()
|
||||
.any(|line| line.contains("Credits:") && line.contains("Unlimited")),
|
||||
"expected Credits: Unlimited line, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_snapshot_shows_positive_credits() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let config = test_config(&temp_home);
|
||||
let auth_manager = test_auth_manager(&config);
|
||||
let usage = TokenUsage::default();
|
||||
let captured_at = chrono::Local
|
||||
.with_ymd_and_hms(2024, 3, 4, 5, 6, 7)
|
||||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("12.5".to_string()),
|
||||
}),
|
||||
};
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
let composite = new_status_output(
|
||||
&config,
|
||||
&auth_manager,
|
||||
&usage,
|
||||
Some(&usage),
|
||||
&None,
|
||||
Some(&rate_display),
|
||||
captured_at,
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(120));
|
||||
assert!(
|
||||
rendered
|
||||
.iter()
|
||||
.any(|line| line.contains("Credits:") && line.contains("13 credits")),
|
||||
"expected Credits line with rounded credits, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_snapshot_hides_zero_credits() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let config = test_config(&temp_home);
|
||||
let auth_manager = test_auth_manager(&config);
|
||||
let usage = TokenUsage::default();
|
||||
let captured_at = chrono::Local
|
||||
.with_ymd_and_hms(2024, 4, 5, 6, 7, 8)
|
||||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("0".to_string()),
|
||||
}),
|
||||
};
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
let composite = new_status_output(
|
||||
&config,
|
||||
&auth_manager,
|
||||
&usage,
|
||||
Some(&usage),
|
||||
&None,
|
||||
Some(&rate_display),
|
||||
captured_at,
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(120));
|
||||
assert!(
|
||||
rendered.iter().all(|line| !line.contains("Credits:")),
|
||||
"expected no Credits line, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_snapshot_hides_when_has_no_credits_flag() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let config = test_config(&temp_home);
|
||||
let auth_manager = test_auth_manager(&config);
|
||||
let usage = TokenUsage::default();
|
||||
let captured_at = chrono::Local
|
||||
.with_ymd_and_hms(2024, 5, 6, 7, 8, 9)
|
||||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: Some(CreditsSnapshot {
|
||||
has_credits: false,
|
||||
unlimited: true,
|
||||
balance: None,
|
||||
}),
|
||||
};
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
let composite = new_status_output(
|
||||
&config,
|
||||
&auth_manager,
|
||||
&usage,
|
||||
Some(&usage),
|
||||
&None,
|
||||
Some(&rate_display),
|
||||
captured_at,
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(120));
|
||||
assert!(
|
||||
rendered.iter().all(|line| !line.contains("Credits:")),
|
||||
"expected no Credits line when has_credits is false, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_card_token_usage_excludes_cached_tokens() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
@@ -258,6 +409,7 @@ fn status_snapshot_truncates_in_narrow_terminal() {
|
||||
resets_at: Some(reset_at_from(&captured_at, 600)),
|
||||
}),
|
||||
secondary: None,
|
||||
credits: None,
|
||||
};
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
|
||||
@@ -321,6 +473,64 @@ fn status_snapshot_shows_missing_limits_message() {
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_snapshot_includes_credits_and_limits() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let mut config = test_config(&temp_home);
|
||||
config.model = "gpt-5.1-codex".to_string();
|
||||
config.cwd = PathBuf::from("/workspace/tests");
|
||||
|
||||
let auth_manager = test_auth_manager(&config);
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1_500,
|
||||
cached_input_tokens: 100,
|
||||
output_tokens: 600,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 2_200,
|
||||
};
|
||||
|
||||
let captured_at = chrono::Local
|
||||
.with_ymd_and_hms(2024, 7, 8, 9, 10, 11)
|
||||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 45.0,
|
||||
window_minutes: Some(300),
|
||||
resets_at: Some(reset_at_from(&captured_at, 900)),
|
||||
}),
|
||||
secondary: Some(RateLimitWindow {
|
||||
used_percent: 30.0,
|
||||
window_minutes: Some(10_080),
|
||||
resets_at: Some(reset_at_from(&captured_at, 2_700)),
|
||||
}),
|
||||
credits: Some(CreditsSnapshot {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("37.5".to_string()),
|
||||
}),
|
||||
};
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
|
||||
let composite = new_status_output(
|
||||
&config,
|
||||
&auth_manager,
|
||||
&usage,
|
||||
Some(&usage),
|
||||
&None,
|
||||
Some(&rate_display),
|
||||
captured_at,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
for line in &mut rendered_lines {
|
||||
*line = line.replace('\\', "/");
|
||||
}
|
||||
}
|
||||
let sanitized = sanitize_directory(rendered_lines).join("\n");
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_snapshot_shows_empty_limits_message() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
@@ -340,6 +550,7 @@ fn status_snapshot_shows_empty_limits_message() {
|
||||
let snapshot = RateLimitSnapshot {
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: None,
|
||||
};
|
||||
let captured_at = chrono::Local
|
||||
.with_ymd_and_hms(2024, 6, 7, 8, 9, 10)
|
||||
@@ -397,6 +608,66 @@ fn status_snapshot_shows_stale_limits_message() {
|
||||
window_minutes: Some(10_080),
|
||||
resets_at: Some(reset_at_from(&captured_at, 1_800)),
|
||||
}),
|
||||
credits: None,
|
||||
};
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
let now = captured_at + ChronoDuration::minutes(20);
|
||||
|
||||
let composite = new_status_output(
|
||||
&config,
|
||||
&auth_manager,
|
||||
&usage,
|
||||
Some(&usage),
|
||||
&None,
|
||||
Some(&rate_display),
|
||||
now,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
for line in &mut rendered_lines {
|
||||
*line = line.replace('\\', "/");
|
||||
}
|
||||
}
|
||||
let sanitized = sanitize_directory(rendered_lines).join("\n");
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_snapshot_cached_limits_hide_credits_without_flag() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let mut config = test_config(&temp_home);
|
||||
config.model = "gpt-5.1-codex".to_string();
|
||||
config.cwd = PathBuf::from("/workspace/tests");
|
||||
|
||||
let auth_manager = test_auth_manager(&config);
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 900,
|
||||
cached_input_tokens: 200,
|
||||
output_tokens: 350,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 1_450,
|
||||
};
|
||||
|
||||
let captured_at = chrono::Local
|
||||
.with_ymd_and_hms(2024, 9, 10, 11, 12, 13)
|
||||
.single()
|
||||
.expect("timestamp");
|
||||
let snapshot = RateLimitSnapshot {
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 60.0,
|
||||
window_minutes: Some(300),
|
||||
resets_at: Some(reset_at_from(&captured_at, 1_200)),
|
||||
}),
|
||||
secondary: Some(RateLimitWindow {
|
||||
used_percent: 35.0,
|
||||
window_minutes: Some(10_080),
|
||||
resets_at: Some(reset_at_from(&captured_at, 2_400)),
|
||||
}),
|
||||
credits: Some(CreditsSnapshot {
|
||||
has_credits: false,
|
||||
unlimited: false,
|
||||
balance: Some("80".to_string()),
|
||||
}),
|
||||
};
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
let now = captured_at + ChronoDuration::minutes(20);
|
||||
|
||||
Reference in New Issue
Block a user