Skip to content
Merged
10 changes: 8 additions & 2 deletions codex-rs/core/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,9 +398,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,
}
}
Expand Down
44 changes: 31 additions & 13 deletions codex-rs/core/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ use crate::protocol::SessionConfiguredEvent;
use crate::protocol::StreamErrorEvent;
use crate::protocol::Submission;
use crate::protocol::TaskCompleteEvent;
use crate::protocol::TokenUsageInfo;
use crate::protocol::TurnDiffEvent;
use crate::protocol::WebSearchBeginEvent;
use crate::rollout::RolloutRecorder;
Expand Down Expand Up @@ -261,6 +262,7 @@ struct State {
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
pending_input: Vec<ResponseInputItem>,
history: ConversationHistory,
token_info: Option<TokenUsageInfo>,
}

/// Context for an initialized model agent
Expand Down Expand Up @@ -1767,15 +1769,23 @@ async fn try_run_turn(
response_id: _,
token_usage,
} => {
if let Some(token_usage) = token_usage {
sess.tx_event
.send(Event {
id: sub_id.to_string(),
msg: EventMsg::TokenCount(token_usage),
})
.await
.ok();
}
let info = {
let mut st = sess.state.lock_unchecked();
let info = TokenUsageInfo::new_or_append(
&st.token_info,
&token_usage,
turn_context.client.get_model_context_window(),
);
st.token_info = info.clone();
info
};
sess.tx_event
.send(Event {
id: sub_id.to_string(),
msg: EventMsg::TokenCount(crate::protocol::TokenCountEvent { info }),
})
.await
.ok();

let unified_diff = turn_diff_tracker.get_unified_diff();
if let Ok(Some(unified_diff)) = unified_diff {
Expand Down Expand Up @@ -2841,13 +2851,21 @@ async fn drain_to_completed(
response_id: _,
token_usage,
}) => {
// some providers don't return token usage, so we default
// TODO: consider approximate token usage
let token_usage = token_usage.unwrap_or_default();
let info = {
let mut st = sess.state.lock_unchecked();
let info = TokenUsageInfo::new_or_append(
&st.token_info,
&token_usage,
turn_context.client.get_model_context_window(),
);
st.token_info = info.clone();
info
};

sess.tx_event
.send(Event {
id: sub_id.to_string(),
msg: EventMsg::TokenCount(token_usage),
msg: EventMsg::TokenCount(crate::protocol::TokenCountEvent { info }),
})
.await
.ok();
Expand Down
10 changes: 8 additions & 2 deletions codex-rs/exec/src/event_processor_with_human_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,14 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
return CodexStatus::InitiateShutdown;
}
EventMsg::TokenCount(token_usage) => {
ts_println!(self, "tokens used: {}", token_usage.blended_total());
EventMsg::TokenCount(ev) => {
if let Some(usage_info) = ev.info {
ts_println!(
self,
"tokens used: {}",
usage_info.total_token_usage.blended_total()
);
}
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
if !self.answer_started {
Expand Down
74 changes: 63 additions & 11 deletions codex-rs/protocol/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,9 +417,9 @@ pub enum EventMsg {
/// Agent has completed all actions
TaskComplete(TaskCompleteEvent),

/// Token count event, sent periodically to report the number of tokens
/// used in the current session.
TokenCount(TokenUsage),
/// Usage update for the current session, including totals and last turn.
/// Optional means unknown — UIs should not display when `None`.
TokenCount(TokenCountEvent),

/// Agent text output message
AgentMessage(AgentMessageEvent),
Expand Down Expand Up @@ -521,12 +521,54 @@ pub struct TaskStartedEvent {
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TokenUsage {
pub input_tokens: u64,
pub cached_input_tokens: Option<u64>,
pub cached_input_tokens: u64,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General comment: we should not used unsigned int for this kind of thing. Unsigned arithmetic is not as good as signed one, it can fail gracefully in a lot of ways etc
This is a good reading on the topic (TLDR; this is not because it must be non-negative that we should use unsigned ints)
https://hamstergene.github.io/posts/2021-10-30-do-not-use-unsigned-for-nonnegativity/

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to know! I won't churn this PR but make sure to avoid unsigned where possible.

pub output_tokens: u64,
pub reasoning_output_tokens: Option<u64>,
pub reasoning_output_tokens: u64,
pub total_tokens: u64,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
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);
}
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 TokenCountEvent {
pub info: Option<TokenUsageInfo>,
}

// Includes prompts, tools and space to call compact.
const BASELINE_TOKENS: u64 = 12000;

Expand All @@ -536,7 +578,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 {
Expand All @@ -554,7 +596,7 @@ 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.
Expand All @@ -579,6 +621,15 @@ impl TokenUsage {
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;
}
}

#[derive(Debug, Clone, Deserialize, Serialize)]
Expand Down Expand Up @@ -606,10 +657,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()
}
)
}
}
Expand Down
34 changes: 12 additions & 22 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use codex_core::protocol::TokenUsage;
use codex_core::protocol::TokenUsageInfo;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
Expand Down Expand Up @@ -63,12 +63,6 @@ struct AttachedImage {
path: PathBuf,
}

struct TokenUsageInfo {
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
model_context_window: Option<u64>,
}

pub(crate) struct ChatComposer {
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
Expand Down Expand Up @@ -166,17 +160,8 @@ impl ChatComposer {
/// Update the cached *context-left* percentage and refresh the placeholder
/// text. The UI relies on the placeholder to convey the remaining
/// context when the composer is empty.
pub(crate) fn set_token_usage(
&mut self,
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
model_context_window: Option<u64>,
) {
self.token_usage_info = Some(TokenUsageInfo {
total_token_usage,
last_token_usage,
model_context_window,
});
pub(crate) fn set_token_usage(&mut self, token_info: Option<TokenUsageInfo>) {
self.token_usage_info = token_info;
}

/// Record the history metadata advertised by `SessionConfiguredEvent` so
Expand Down Expand Up @@ -1290,11 +1275,16 @@ impl WidgetRef for ChatComposer {
} else {
100
};
let context_style = if percent_remaining < 20 {
Style::default().fg(Color::Yellow)
} else {
Style::default().add_modifier(Modifier::DIM)
};
hint.push(" ".into());
hint.push(
Span::from(format!("{percent_remaining}% context left"))
.style(Style::default().add_modifier(Modifier::DIM)),
);
hint.push(Span::styled(
format!("{percent_remaining}% context left"),
context_style,
));
}
}

Expand Down
12 changes: 3 additions & 9 deletions codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::app_event_sender::AppEventSender;
use crate::tui::FrameRequester;
use crate::user_approval_widget::ApprovalRequest;
use bottom_pane_view::BottomPaneView;
use codex_core::protocol::TokenUsage;
use codex_core::protocol::TokenUsageInfo;
use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
Expand Down Expand Up @@ -358,14 +358,8 @@ impl BottomPane {

/// Update the *context-window remaining* indicator in the composer. This
/// is forwarded directly to the underlying `ChatComposer`.
pub(crate) fn set_token_usage(
&mut self,
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
model_context_window: Option<u64>,
) {
self.composer
.set_token_usage(total_token_usage, last_token_usage, model_context_window);
pub(crate) fn set_token_usage(&mut self, token_info: Option<TokenUsageInfo>) {
self.composer.set_token_usage(token_info);
self.request_redraw();
}

Expand Down
Loading
Loading