feat(core,tui): surface rate limit snapshots in token events

This commit is contained in:
James Peter
2025-09-22 08:55:40 +10:00
parent 75042c8f65
commit a9fc289aad
8 changed files with 186 additions and 58 deletions
+13 -2
View File
@@ -551,6 +551,11 @@ impl ModelClient {
self.config.model_family.clone()
}
#[allow(dead_code)]
pub fn get_model_context_window(&self) -> Option<u64> {
self.config.model_context_window
}
// duplicate of earlier helpers removed during merge cleanup
#[allow(dead_code)]
@@ -599,9 +604,15 @@ impl From<ResponseCompletedUsage> for TokenUsage {
fn from(val: ResponseCompletedUsage) -> Self {
TokenUsage {
input_tokens: val.input_tokens,
cached_input_tokens: val.input_tokens_details.map(|d| d.cached_tokens),
cached_input_tokens: val
.input_tokens_details
.map(|d| d.cached_tokens)
.unwrap_or(0),
output_tokens: val.output_tokens,
reasoning_output_tokens: val.output_tokens_details.map(|d| d.reasoning_tokens),
reasoning_output_tokens: val
.output_tokens_details
.map(|d| d.reasoning_tokens)
.unwrap_or(0),
total_tokens: val.total_tokens,
}
}
+36 -4
View File
@@ -518,6 +518,9 @@ use crate::protocol::Op;
use crate::protocol::PatchApplyBeginEvent;
use crate::protocol::PatchApplyEndEvent;
use crate::protocol::RecordedEvent;
use crate::protocol::RateLimitSnapshotEvent;
use crate::protocol::TokenCountEvent;
use crate::protocol::TokenUsageInfo;
use crate::protocol::ReviewDecision;
use crate::protocol::SandboxPolicy;
use crate::protocol::SessionConfiguredEvent;
@@ -748,6 +751,8 @@ struct State {
/// Background execs by call_id
background_execs: std::collections::HashMap<String, BackgroundExecState>,
next_internal_sub_id: u64,
token_usage_info: Option<TokenUsageInfo>,
latest_rate_limits: Option<RateLimitSnapshotEvent>,
}
#[derive(Clone)]
@@ -3790,9 +3795,29 @@ async fn try_run_turn(
response_id: _,
token_usage,
} => {
if let Some(token_usage) = token_usage {
let (new_info, rate_limits, should_emit);
{
let mut state = sess.state.lock().unwrap();
let info = TokenUsageInfo::new_or_append(
&state.token_usage_info,
&token_usage,
sess.client.get_model_context_window(),
);
let limits = state.latest_rate_limits.clone();
let emit = info.is_some() || limits.is_some();
state.token_usage_info = info.clone();
new_info = info;
rate_limits = limits;
should_emit = emit;
}
if should_emit {
let payload = TokenCountEvent {
info: new_info,
rate_limits,
};
sess.tx_event
.send(sess.make_event(&sub_id, EventMsg::TokenCount(token_usage)))
.send(sess.make_event(&sub_id, EventMsg::TokenCount(payload)))
.await
.ok();
}
@@ -3850,8 +3875,15 @@ async fn try_run_turn(
sess.tx_event.send(stamped).await.ok();
}
}
ResponseEvent::RateLimits(_rl) => {
// Upstream emits periodic rate limit snapshots. No-op for now.
ResponseEvent::RateLimits(snapshot) => {
let mut state = sess.state.lock().unwrap();
state.latest_rate_limits = Some(RateLimitSnapshotEvent {
primary_used_percent: snapshot.primary_used_percent,
weekly_used_percent: snapshot.weekly_used_percent,
primary_to_weekly_ratio_percent: snapshot.primary_to_weekly_ratio_percent,
primary_window_minutes: snapshot.primary_window_minutes,
weekly_window_minutes: snapshot.weekly_window_minutes,
});
}
// Note: ReasoningSummaryPartAdded handled above without scratchpad mutation.
}
+100 -10
View File
@@ -560,8 +560,8 @@ pub enum EventMsg {
TaskComplete(TaskCompleteEvent),
/// Token count event, sent periodically to report the number of tokens
/// used in the current session.
TokenCount(TokenUsage),
/// used in the current session and the latest rate limit snapshot.
TokenCount(TokenCountEvent),
/// Agent text output message
AgentMessage(AgentMessageEvent),
@@ -672,9 +672,9 @@ pub struct TaskCompleteEvent {
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TokenUsage {
pub input_tokens: u64,
pub cached_input_tokens: Option<u64>,
pub cached_input_tokens: u64,
pub output_tokens: u64,
pub reasoning_output_tokens: Option<u64>,
pub reasoning_output_tokens: u64,
pub total_tokens: u64,
}
@@ -684,7 +684,7 @@ impl TokenUsage {
}
pub fn cached_input(&self) -> u64 {
self.cached_input_tokens.unwrap_or(0)
self.cached_input_tokens
}
pub fn non_cached_input(&self) -> u64 {
@@ -702,8 +702,97 @@ impl TokenUsage {
/// This will be off for the current turn and pending function calls.
pub fn tokens_in_context_window(&self) -> u64 {
self.total_tokens
.saturating_sub(self.reasoning_output_tokens.unwrap_or(0))
.saturating_sub(self.reasoning_output_tokens)
}
/// Estimate the remaining user-controllable percentage of the model's context window.
pub fn percent_of_context_window_remaining(&self, context_window: u64) -> u8 {
if context_window <= BASELINE_TOKENS {
return 0;
}
let effective_window = context_window - BASELINE_TOKENS;
let used = self
.tokens_in_context_window()
.saturating_sub(BASELINE_TOKENS);
let remaining = effective_window.saturating_sub(used);
((remaining as f32 / effective_window as f32) * 100.0).clamp(0.0, 100.0) as u8
}
/// In-place element-wise sum of token counts.
pub fn add_assign(&mut self, other: &TokenUsage) {
self.input_tokens += other.input_tokens;
self.cached_input_tokens += other.cached_input_tokens;
self.output_tokens += other.output_tokens;
self.reasoning_output_tokens += other.reasoning_output_tokens;
self.total_tokens += other.total_tokens;
}
}
/// Includes prompts, tools and space to call compact.
const BASELINE_TOKENS: u64 = 12_000;
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TokenUsageInfo {
pub total_token_usage: TokenUsage,
pub last_token_usage: TokenUsage,
pub model_context_window: Option<u64>,
}
impl TokenUsageInfo {
pub fn new_or_append(
info: &Option<TokenUsageInfo>,
last: &Option<TokenUsage>,
model_context_window: Option<u64>,
) -> Option<Self> {
if info.is_none() && last.is_none() {
return None;
}
let mut info = match info {
Some(info) => info.clone(),
None => Self {
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
model_context_window,
},
};
if let Some(last) = last {
info.append_last_usage(last);
}
if info.model_context_window.is_none() {
info.model_context_window = model_context_window;
}
Some(info)
}
pub fn append_last_usage(&mut self, last: &TokenUsage) {
self.total_token_usage.add_assign(last);
self.last_token_usage = last.clone();
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RateLimitSnapshotEvent {
/// Percentage (0-100) of the primary window that has been consumed.
pub primary_used_percent: f64,
/// Percentage (0-100) of the protection window that has been consumed.
pub weekly_used_percent: f64,
/// Size of the primary window relative to weekly (0-100).
pub primary_to_weekly_ratio_percent: f64,
/// Rolling window duration for the primary limit, in minutes.
pub primary_window_minutes: u64,
/// Rolling window duration for the weekly limit, in minutes.
pub weekly_window_minutes: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TokenCountEvent {
pub info: Option<TokenUsageInfo>,
pub rate_limits: Option<RateLimitSnapshotEvent>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -756,10 +845,11 @@ impl fmt::Display for FinalOutput {
String::new()
},
token_usage.output_tokens,
token_usage
.reasoning_output_tokens
.map(|r| format!(" (reasoning {r})"))
.unwrap_or_default()
if token_usage.reasoning_output_tokens > 0 {
format!(" (reasoning {})", token_usage.reasoning_output_tokens)
} else {
String::new()
}
)
}
}
@@ -284,12 +284,21 @@ impl EventProcessor for EventProcessorWithHumanOutput {
return CodexStatus::InitiateShutdown;
}
EventMsg::TokenCount(ev) => {
// Our fork carries TokenUsage directly
ts_println!(
self,
"tokens used: {}",
format_with_separators(ev.blended_total())
);
if let Some(info) = &ev.info {
ts_println!(
self,
"tokens used: {}",
format_with_separators(info.total_token_usage.blended_total())
);
}
if let Some(snapshot) = &ev.rate_limits {
ts_println!(
self,
"rate limits: hourly {:.1}% • weekly {:.1}%",
snapshot.primary_used_percent,
snapshot.weekly_used_percent
);
}
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
if !self.answer_started {
@@ -480,7 +480,7 @@ impl ChatComposer {
.token_usage_info
.as_ref()
.map(|info| info.initial_prompt_tokens)
.unwrap_or_else(|| last_token_usage.cached_input_tokens.unwrap_or(0));
.unwrap_or_else(|| last_token_usage.cached_input_tokens);
self.token_usage_info = Some(TokenUsageInfo {
_total_token_usage: total_token_usage,
+12 -31
View File
@@ -151,6 +151,7 @@ use codex_file_search::FileMatch;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::plan_tool::StepStatus;
use codex_core::protocol::RateLimitSnapshotEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use ratatui::style::Stylize;
@@ -225,6 +226,7 @@ pub(crate) struct ChatWidget<'a> {
initial_user_message: Option<UserMessage>,
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
latest_rate_limits: Option<RateLimitSnapshotEvent>,
content_buffer: String,
// Buffer for streaming assistant answer text; we do not surface partial
// We wait for the final AgentMessage event and then emit the full text
@@ -2028,6 +2030,7 @@ impl ChatWidget<'_> {
),
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
latest_rate_limits: None,
content_buffer: String::new(),
last_assistant_message: None,
exec: ExecState {
@@ -2219,6 +2222,7 @@ impl ChatWidget<'_> {
initial_user_message: None,
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
latest_rate_limits: None,
content_buffer: String::new(),
last_assistant_message: None,
exec: ExecState {
@@ -4132,9 +4136,14 @@ impl ChatWidget<'_> {
}
self.mark_needs_redraw();
}
EventMsg::TokenCount(token_usage) => {
self.total_token_usage = add_token_usage(&self.total_token_usage, &token_usage);
self.last_token_usage = token_usage;
EventMsg::TokenCount(event) => {
if let Some(info) = &event.info {
self.total_token_usage = info.total_token_usage.clone();
self.last_token_usage = info.last_token_usage.clone();
}
if let Some(snapshot) = event.rate_limits {
self.latest_rate_limits = Some(snapshot);
}
self.bottom_pane.set_token_usage(
self.total_token_usage.clone(),
self.last_token_usage.clone(),
@@ -14465,34 +14474,6 @@ impl WidgetRef for &ChatWidget<'_> {
}
}
fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenUsage {
let cached_input_tokens = match (
current_usage.cached_input_tokens,
new_usage.cached_input_tokens,
) {
(Some(current), Some(new)) => Some(current + new),
(Some(current), None) => Some(current),
(None, Some(new)) => Some(new),
(None, None) => None,
};
let reasoning_output_tokens = match (
current_usage.reasoning_output_tokens,
new_usage.reasoning_output_tokens,
) {
(Some(current), Some(new)) => Some(current + new),
(Some(current), None) => Some(current),
(None, Some(new)) => Some(new),
(None, None) => None,
};
TokenUsage {
input_tokens: current_usage.input_tokens + new_usage.input_tokens,
cached_input_tokens,
output_tokens: current_usage.output_tokens + new_usage.output_tokens,
reasoning_output_tokens,
total_tokens: current_usage.total_tokens + new_usage.total_tokens,
}
}
// Coalesce adjacent Read entries of the same file with contiguous ranges in a rendered lines vector.
// Expects the vector to contain a header line at index 0 (e.g., "Read"). Modifies in place.
#[allow(dead_code)]
+1
View File
@@ -131,6 +131,7 @@ fn make_chatwidget_manual() -> (
initial_user_message: None,
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
latest_rate_limits: None,
stream: StreamController::new(cfg),
last_stream_kind: None,
running_commands: HashMap::new(),
+8 -4
View File
@@ -9408,10 +9408,14 @@ pub(crate) fn new_status_output(
" • Input: ".into(),
format_with_separators(last_usage.non_cached_input()).into(),
];
if let Some(cached) = last_usage.cached_input_tokens {
if cached > 0 {
input_line_spans.push(format!(" (+ {} cached)", format_with_separators(cached)).into());
}
if last_usage.cached_input_tokens > 0 {
input_line_spans.push(
format!(
" (+ {} cached)",
format_with_separators(last_usage.cached_input_tokens)
)
.into(),
);
}
lines.push(Line::from(input_line_spans));
// Output: <output>