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:
zhao-oai
2025-11-19 10:49:35 -08:00
committed by GitHub
Unverified
parent b3d320433f
commit 72af589398
15 changed files with 548 additions and 41 deletions
@@ -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/")]
+3 -1
View File
@@ -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);
+28 -9
View File
@@ -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
View File
@@ -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;
+33 -1
View File
@@ -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()
}
+1
View File
@@ -499,6 +499,7 @@ mod tests {
window_minutes: Some(120),
resets_at: Some(secondary_reset_at),
}),
credits: None,
}
}
+6 -3
View File
@@ -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
+8
View File
@@ -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;
+1
View File
@@ -81,6 +81,7 @@ fn snapshot(percent: f64) -> RateLimitSnapshot {
resets_at: None,
}),
secondary: None,
credits: None,
}
}
+36 -20
View File
@@ -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);
}
}
+89 -7
View File
@@ -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() {
@@ -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. │
╰─────────────────────────────────────────────────────────────────────╯
@@ -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 │
╰───────────────────────────────────────────────────────────────────╯
+271
View File
@@ -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);