Expose token usage totals in statusline state (#61408)

## What changed

Add `thread.usage.total` to the provider-facing statusline snapshot with total input, cache-read input, cache-write input, other input, and output token counts. Preserve cache-write counts when converting app-server token reports for the TUI.

Omit totals when usage is unavailable, the breakdown is invalid, or the report represents a synthetic context fill. Keep measured zero totals.

## Testing

Add coverage for the reported token breakdown, cache counts exceeding total input, synthetic context fills, and measured zero usage.

GitOrigin-RevId: 6c506af30d6e51a49a780a0316304ef9c067fe43
This commit is contained in:
Joe Savona
2026-11-14 00:05:32 +00:00
committed by copyberry
parent 38cdc4d655
commit 91ae3d10bf
8 changed files with 148 additions and 15 deletions
+2
View File
@@ -1211,6 +1211,7 @@ async fn active_thread_drain_yields_after_frame_deadline_without_dropping_events
crate::token_usage::TokenUsage {
input_tokens: 4,
cached_input_tokens: 1,
cache_write_input_tokens: 0,
output_tokens: 5,
reasoning_output_tokens: 0,
total_tokens: 10,
@@ -1239,6 +1240,7 @@ async fn active_thread_drain_yields_after_frame_deadline_without_dropping_events
crate::token_usage::TokenUsage {
input_tokens: 4,
cached_input_tokens: 1,
cache_write_input_tokens: 0,
output_tokens: 10,
reasoning_output_tokens: 0,
total_tokens: 15,
+2 -14
View File
@@ -953,20 +953,8 @@ fn request_permissions_from_params(
fn token_usage_info_from_app_server(token_usage: ThreadTokenUsage) -> TokenUsageInfo {
TokenUsageInfo {
total_token_usage: TokenUsage {
total_tokens: token_usage.total.total_tokens,
input_tokens: token_usage.total.input_tokens,
cached_input_tokens: token_usage.total.cached_input_tokens,
output_tokens: token_usage.total.output_tokens,
reasoning_output_tokens: token_usage.total.reasoning_output_tokens,
},
last_token_usage: TokenUsage {
total_tokens: token_usage.last.total_tokens,
input_tokens: token_usage.last.input_tokens,
cached_input_tokens: token_usage.last.cached_input_tokens,
output_tokens: token_usage.last.output_tokens,
reasoning_output_tokens: token_usage.last.reasoning_output_tokens,
},
total_token_usage: token_usage.total.into(),
last_token_usage: token_usage.last.into(),
model_context_window: token_usage.model_context_window,
}
}
@@ -1,5 +1,7 @@
//! Projects the displayed conversation into the provider-facing statusline snapshot.
mod usage;
use super::ChatWidget;
use super::normalize_thread_name;
use super::status_state::TerminalTitleStatusKind as TerminalTitleStatus;
@@ -44,6 +46,7 @@ struct StatuslineStateThread {
agent: StatuslineStateAgent,
model: StatuslineStateModel,
workspace: StatuslineStateWorkspace,
usage: usage::Usage,
#[serde(skip_serializing_if = "Option::is_none")]
approval: Option<StatuslineStateApproval>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -248,6 +251,7 @@ impl ChatWidget {
name,
agent,
model,
usage: self.into(),
workspace: StatuslineStateWorkspace {
cwd: codex_utils_path_uri::LegacyAppPathString::from_path(
self.status_line_workspace_cwd(),
@@ -0,0 +1,62 @@
//! Provider-facing usage comes from the same token report as the built-in footer.
use super::ChatWidget;
use crate::token_usage::TokenUsage;
use serde::Serialize;
#[derive(Serialize)]
pub(super) struct Usage {
#[serde(skip_serializing_if = "Option::is_none")]
total: Option<Counts>,
}
#[derive(Serialize)]
struct Counts {
total_input_tokens: i64,
cache_read_input_tokens: i64,
cache_write_input_tokens: i64,
other_input_tokens: i64,
output_tokens: i64,
}
impl Counts {
fn reported(usage: &TokenUsage) -> Option<Self> {
let other_input_tokens = usage
.input_tokens
.checked_sub(usage.cached_input_tokens)?
.checked_sub(usage.cache_write_input_tokens)?;
if [
other_input_tokens,
usage.cached_input_tokens,
usage.cache_write_input_tokens,
usage.output_tokens,
]
.into_iter()
.any(|count| count < 0)
{
return None;
}
// A synthetic context fill reports its size without a token breakdown.
if usage.total_tokens != 0 && usage.input_tokens == 0 && usage.output_tokens == 0 {
return None;
}
Some(Self {
total_input_tokens: usage.input_tokens,
cache_read_input_tokens: usage.cached_input_tokens,
cache_write_input_tokens: usage.cache_write_input_tokens,
other_input_tokens,
output_tokens: usage.output_tokens,
})
}
}
impl From<&ChatWidget> for Usage {
fn from(chat: &ChatWidget) -> Self {
Self {
total: chat
.token_info
.as_ref()
.and_then(|info| Counts::reported(&info.total_token_usage)),
}
}
}
+1 -1
View File
@@ -515,7 +515,7 @@ fn token_usage_breakdown(usage: TokenUsage) -> codex_app_server_protocol::TokenU
total_tokens: usage.total_tokens,
input_tokens: usage.input_tokens,
cached_input_tokens: usage.cached_input_tokens,
cache_write_input_tokens: 0,
cache_write_input_tokens: usage.cache_write_input_tokens,
output_tokens: usage.output_tokens,
reasoning_output_tokens: usage.reasoning_output_tokens,
}
@@ -34,6 +34,7 @@ async fn statusline_state_projects_the_displayed_settings() {
"config": {"statusline_items": ["run-state"]},
"thread": {
"workspace": {"cwd": expected_cwd},
"usage": {},
"id": id, "name": "Saved carts", "agent": {"state": "ready"},
"model": {"id": "gpt-5.5", "display_name": "Test model", "provider": "openai",
"reasoning_level": "none", "service_tier": "acme-priority", "service_tier_display_name": "Turbo"},
@@ -47,3 +48,47 @@ async fn statusline_state_projects_the_displayed_settings() {
let state = serde_json::to_value(chat.statusline_state()).unwrap();
assert_eq!(state["thread"]["model"]["reasoning_level"], "default");
}
#[tokio::test]
async fn statusline_totals_use_the_reported_token_breakdown() {
let (mut chat, _events, _ops) = make_chatwidget_manual(Some("gpt-5.2")).await;
chat.handle_thread_session(configured_thread_session(ThreadId::new()));
let mut info =
helpers::make_token_info(/*total_tokens*/ 15, /*context_window*/ 100_000);
info.total_token_usage = TokenUsage {
input_tokens: 110,
cached_input_tokens: 60,
cache_write_input_tokens: 10,
output_tokens: 20,
total_tokens: 130,
..Default::default()
};
helpers::handle_token_count(&mut chat, Some(info.clone()));
let state = serde_json::to_value(chat.statusline_state()).unwrap();
assert_eq!(
state["thread"]["usage"]["total"],
json!({
"total_input_tokens": 110, "cache_read_input_tokens": 60,
"cache_write_input_tokens": 10, "other_input_tokens": 40, "output_tokens": 20
})
);
info.total_token_usage.cache_write_input_tokens = 51;
for (report, has_total) in [
(info, false), // The cache counts exceed total input.
(
helpers::make_token_info(
/*total_tokens*/ 100_000, /*context_window*/ 100_000,
),
false,
), // Synthetic context fill.
(
helpers::make_token_info(/*total_tokens*/ 0, /*context_window*/ 100_000),
true,
), // Measured zero.
] {
helpers::handle_token_count(&mut chat, Some(report));
let state = serde_json::to_value(chat.statusline_state()).unwrap();
assert_eq!(state["thread"]["usage"].get("total").is_some(), has_total);
}
}
+17
View File
@@ -283,6 +283,7 @@ async fn status_snapshot_includes_reasoning_details() {
let usage = TokenUsage {
input_tokens: 1_200,
cached_input_tokens: 200,
cache_write_input_tokens: 0,
output_tokens: 900,
reasoning_output_tokens: 150,
total_tokens: 2_250,
@@ -981,6 +982,7 @@ async fn status_snapshot_includes_forked_from() {
let usage = TokenUsage {
input_tokens: 800,
cached_input_tokens: 0,
cache_write_input_tokens: 0,
output_tokens: 400,
reasoning_output_tokens: 0,
total_tokens: 1_200,
@@ -1035,6 +1037,7 @@ async fn status_snapshot_includes_monthly_limit() {
let usage = TokenUsage {
input_tokens: 800,
cached_input_tokens: 0,
cache_write_input_tokens: 0,
output_tokens: 400,
reasoning_output_tokens: 0,
total_tokens: 1_200,
@@ -1101,6 +1104,7 @@ async fn status_snapshot_includes_enterprise_monthly_credit_limit() {
let usage = TokenUsage {
input_tokens: 800,
cached_input_tokens: 0,
cache_write_input_tokens: 0,
output_tokens: 400,
reasoning_output_tokens: 0,
total_tokens: 1_200,
@@ -1179,6 +1183,7 @@ async fn status_snapshot_uses_generic_limit_labels_for_unsupported_windows() {
let usage = TokenUsage {
input_tokens: 800,
cached_input_tokens: 0,
cache_write_input_tokens: 0,
output_tokens: 400,
reasoning_output_tokens: 0,
total_tokens: 1_200,
@@ -1468,6 +1473,7 @@ async fn status_card_token_usage_excludes_cached_tokens() {
let usage = TokenUsage {
input_tokens: 1_200,
cached_input_tokens: 200,
cache_write_input_tokens: 0,
output_tokens: 900,
reasoning_output_tokens: 0,
total_tokens: 2_100,
@@ -1570,6 +1576,7 @@ async fn status_snapshot_wraps_in_narrow_terminal() {
let usage = TokenUsage {
input_tokens: 1_200,
cached_input_tokens: 200,
cache_write_input_tokens: 0,
output_tokens: 900,
reasoning_output_tokens: 150,
total_tokens: 2_250,
@@ -1677,6 +1684,7 @@ async fn status_snapshot_shows_missing_limits_message() {
let usage = TokenUsage {
input_tokens: 500,
cached_input_tokens: 0,
cache_write_input_tokens: 0,
output_tokens: 250,
reasoning_output_tokens: 0,
total_tokens: 750,
@@ -1725,6 +1733,7 @@ async fn status_snapshot_uses_default_reasoning_when_config_empty() {
let usage = TokenUsage {
input_tokens: 500,
cached_input_tokens: 0,
cache_write_input_tokens: 0,
output_tokens: 250,
reasoning_output_tokens: 0,
total_tokens: 750,
@@ -1793,6 +1802,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() {
let usage = TokenUsage {
input_tokens: 500,
cached_input_tokens: 0,
cache_write_input_tokens: 0,
output_tokens: 250,
reasoning_output_tokens: 0,
total_tokens: 750,
@@ -1951,6 +1961,7 @@ async fn status_snapshot_includes_credits_and_limits() {
let usage = TokenUsage {
input_tokens: 1_500,
cached_input_tokens: 100,
cache_write_input_tokens: 0,
output_tokens: 600,
reasoning_output_tokens: 0,
total_tokens: 2_200,
@@ -2024,6 +2035,7 @@ async fn status_snapshot_shows_unavailable_limits_message() {
let usage = TokenUsage {
input_tokens: 500,
cached_input_tokens: 0,
cache_write_input_tokens: 0,
output_tokens: 250,
reasoning_output_tokens: 0,
total_tokens: 750,
@@ -2084,6 +2096,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() {
let usage = TokenUsage {
input_tokens: 500,
cached_input_tokens: 0,
cache_write_input_tokens: 0,
output_tokens: 250,
reasoning_output_tokens: 0,
total_tokens: 750,
@@ -2146,6 +2159,7 @@ async fn status_snapshot_shows_stale_limits_message() {
let usage = TokenUsage {
input_tokens: 1_200,
cached_input_tokens: 200,
cache_write_input_tokens: 0,
output_tokens: 900,
reasoning_output_tokens: 150,
total_tokens: 2_250,
@@ -2216,6 +2230,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
let usage = TokenUsage {
input_tokens: 900,
cached_input_tokens: 200,
cache_write_input_tokens: 0,
output_tokens: 350,
reasoning_output_tokens: 0,
total_tokens: 1_450,
@@ -2289,6 +2304,7 @@ async fn status_context_window_uses_last_usage() {
let total_usage = TokenUsage {
input_tokens: 12_800,
cached_input_tokens: 0,
cache_write_input_tokens: 0,
output_tokens: 879,
reasoning_output_tokens: 0,
total_tokens: 102_000,
@@ -2296,6 +2312,7 @@ async fn status_context_window_uses_last_usage() {
let last_usage = TokenUsage {
input_tokens: 12_800,
cached_input_tokens: 0,
cache_write_input_tokens: 0,
output_tokens: 879,
reasoning_output_tokens: 0,
total_tokens: 13_679,
+15
View File
@@ -12,11 +12,26 @@ const BASELINE_TOKENS: i64 = 12000;
pub struct TokenUsage {
pub input_tokens: i64,
pub cached_input_tokens: i64,
#[serde(default)]
pub cache_write_input_tokens: i64,
pub output_tokens: i64,
pub reasoning_output_tokens: i64,
pub total_tokens: i64,
}
impl From<codex_app_server_protocol::TokenUsageBreakdown> for TokenUsage {
fn from(usage: codex_app_server_protocol::TokenUsageBreakdown) -> Self {
Self {
input_tokens: usage.input_tokens,
cached_input_tokens: usage.cached_input_tokens,
cache_write_input_tokens: usage.cache_write_input_tokens,
output_tokens: usage.output_tokens,
reasoning_output_tokens: usage.reasoning_output_tokens,
total_tokens: usage.total_tokens,
}
}
}
impl TokenUsage {
pub fn is_zero(&self) -> bool {
self.total_tokens == 0