Expose thread costs in statusline state and reject stale usage reads (#61410)

## Why

A replacement chat widget can receive a previous widget's usage result for the same thread. Statusline state also lacks the estimated costs available in the built-in status line.

## What changed

- Add optional `thread.usage.cost.total_cost_usd` and `total_credits` fields, using the built-in status line's plan-specific values. Convert micro-units to ordinary units and omit unavailable or negative values.
- Use UUIDs for thread usage requests and verify that each request is still pending before fetching usage or accepting its result.

## Testing

Add regression coverage for rejecting a previous widget's usage result and assertions for serialized costs across supported enterprise plans.

GitOrigin-RevId: 91c3f7cead0249b673979d17cc108ceae1088df2
This commit is contained in:
Joe Savona
2026-11-14 00:07:46 +00:00
committed by copyberry
parent e87cd55ed9
commit 5ca2007abe
8 changed files with 88 additions and 29 deletions
+7 -1
View File
@@ -161,8 +161,14 @@ impl App {
&mut self,
app_server: &AppServerSession,
thread_id: ThreadId,
request_id: u64,
request_id: Uuid,
) {
if !self
.chat_widget
.is_pending_thread_usage(thread_id, request_id)
{
return;
}
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
+1 -1
View File
@@ -138,7 +138,7 @@ impl App {
&mut self,
tui: &mut tui::Tui,
thread_id: ThreadId,
request_id: u64,
request_id: Uuid,
result: std::result::Result<crate::chatwidget::ThreadUsageOutcome, String>,
) -> Result<()> {
let may_update_status = match &result {
+2 -2
View File
@@ -10,7 +10,7 @@ use ratatui::layout::Rect;
use ratatui::layout::Size;
async fn app_with_pending_thread_usage()
-> Result<(Box<App>, AppServerSession, tui::Tui, ThreadId, u64)> {
-> Result<(Box<App>, AppServerSession, tui::Tui, ThreadId, Uuid)> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.chat_widget.handle_thread_session(test_thread_session(
@@ -51,7 +51,7 @@ async fn app_with_pending_thread_usage()
Ok((app, app_server, tui, thread_id, request_id))
}
fn successful_thread_usage(thread_id: ThreadId, request_id: u64) -> AppEvent {
fn successful_thread_usage(thread_id: ThreadId, request_id: Uuid) -> AppEvent {
AppEvent::ThreadUsageLoaded {
thread_id,
request_id,
+2 -2
View File
@@ -959,13 +959,13 @@ pub(crate) enum AppEvent {
/// Fetch backend-estimated usage for the currently visible enterprise thread.
RefreshThreadUsage {
thread_id: ThreadId,
request_id: u64,
request_id: Uuid,
},
/// Result of fetching backend-estimated usage for a specific thread.
ThreadUsageLoaded {
thread_id: ThreadId,
request_id: u64,
request_id: Uuid,
result: Result<ThreadUsageOutcome, String>,
},
@@ -13,7 +13,6 @@ use crate::model_catalog::LUNA_RESERVE_MODEL;
use crate::status::format_credit_micros;
use crate::status::format_estimated_usd_micros;
use crate::status::format_tokens_compact;
use crate::status::thread_cost_micros;
use crate::terminal_program_status::ProgramStatus;
use codex_app_server_protocol::AskForApproval;
use codex_config::ConfigLayerSource;
@@ -831,13 +830,10 @@ impl ChatWidget {
)
}),
StatusLineItem::ThreadCredits => self
.estimated_thread_usage()
.filter(|_| self.plan_type != Some(PlanType::Ent26))
.map(|usage| usage.estimated_usage_credits_micros)
.status_line_thread_credits_micros()
.map(|credits| format!("{} credits", format_credit_micros(credits))),
StatusLineItem::EstimatedThreadCost => self
.estimated_thread_usage()
.and_then(|usage| thread_cost_micros(usage, self.plan_type))
.status_line_thread_cost_micros()
.and_then(format_estimated_usd_micros),
StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()),
StatusLineItem::FastMode => Some(
@@ -9,6 +9,16 @@ pub(super) struct Usage {
context: Context,
#[serde(skip_serializing_if = "Option::is_none")]
total: Option<Counts>,
#[serde(skip_serializing_if = "Option::is_none")]
cost: Option<Cost>,
}
#[derive(Serialize)]
struct Cost {
#[serde(skip_serializing_if = "Option::is_none")]
total_cost_usd: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
total_credits: Option<f64>,
}
#[derive(Serialize)]
@@ -64,7 +74,14 @@ impl Counts {
impl From<&ChatWidget> for Usage {
fn from(chat: &ChatWidget) -> Self {
let context_window_size = chat.status_line_context_window_size();
let units = |micros: i64| (micros >= 0).then_some(micros as f64 / 1_000_000.0);
let total_cost_usd = chat.status_line_thread_cost_micros().and_then(units);
let total_credits = chat.status_line_thread_credits_micros().and_then(units);
Self {
cost: (total_cost_usd.is_some() || total_credits.is_some()).then_some(Cost {
total_cost_usd,
total_credits,
}),
context: Context {
context_window_size,
used_tokens: chat
+23 -16
View File
@@ -11,6 +11,7 @@ use super::ThreadId;
use crate::status::StatusHistoryHandle;
use crate::status::thread_cost_micros;
use codex_app_server_protocol::ThreadUsage;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ThreadUsageOutcome {
@@ -39,8 +40,7 @@ struct SelectedThreadUsage {
pub(super) struct ThreadUsageState {
thread_id: Option<ThreadId>,
estimate: Option<ThreadUsage>,
pending_request_id: Option<u64>,
next_request_id: u64,
pending_request_id: Option<Uuid>,
requested: bool,
feature_disabled: bool,
status_history_handles: Vec<StatusHistoryHandle>,
@@ -50,18 +50,14 @@ pub(super) struct ThreadUsageState {
settlement_attempts: usize,
settlement_baseline_credits_micros: Option<i64>,
settlement_baseline_usd_micros: Option<i64>,
settlement_request_id: Option<u64>,
settlement_request_id: Option<Uuid>,
settlement_refresh_due_at: Option<Instant>,
pub(super) replaying_turn_completion: bool,
}
impl ChatWidget {
pub(super) fn clear_thread_usage_state(&mut self) {
let next_request_id = self.thread_usage.next_request_id;
self.thread_usage = ThreadUsageState {
next_request_id,
..ThreadUsageState::default()
};
self.thread_usage = ThreadUsageState::default();
}
pub(super) fn ensure_thread_usage_requested(&mut self) {
@@ -178,16 +174,19 @@ impl ChatWidget {
self.thread_usage.settlement_refresh_due_at = None;
}
pub(crate) fn is_pending_thread_usage(&self, thread_id: ThreadId, request_id: Uuid) -> bool {
self.thread_id == Some(thread_id)
&& self.thread_usage.thread_id == Some(thread_id)
&& self.thread_usage.pending_request_id == Some(request_id)
}
pub(crate) fn finish_thread_usage_refresh(
&mut self,
thread_id: ThreadId,
request_id: u64,
request_id: Uuid,
result: Result<ThreadUsageOutcome, String>,
) -> bool {
if self.thread_id != Some(thread_id)
|| self.thread_usage.thread_id != Some(thread_id)
|| self.thread_usage.pending_request_id != Some(request_id)
{
if !self.is_pending_thread_usage(thread_id, request_id) {
return false;
}
@@ -324,6 +323,16 @@ impl ChatWidget {
true
}
pub(super) fn status_line_thread_credits_micros(&self) -> Option<i64> {
self.estimated_thread_usage()
.filter(|_| self.plan_type != Some(PlanType::Ent26))
.map(|usage| usage.estimated_usage_credits_micros)
}
pub(super) fn status_line_thread_cost_micros(&self) -> Option<i64> {
thread_cost_micros(self.estimated_thread_usage()?, self.plan_type)
}
pub(super) fn estimated_thread_usage(&self) -> Option<&ThreadUsage> {
self.thread_usage.estimate.as_ref()
}
@@ -336,9 +345,7 @@ impl ChatWidget {
return;
}
let request_id = self.thread_usage.next_request_id;
self.thread_usage.next_request_id =
self.thread_usage.next_request_id.wrapping_add(/*rhs*/ 1);
let request_id = Uuid::new_v4();
self.thread_usage.pending_request_id = Some(request_id);
self.thread_usage.requested = true;
self.thread_usage.thread_id = Some(thread_id);
@@ -2,6 +2,7 @@ use super::THREAD_USAGE_RETRY_DELAYS;
use super::THREAD_USAGE_SETTLEMENT_DELAYS;
use super::ThreadUsageOutcome;
use crate::app_event::AppEvent;
use crate::chatwidget::ChatWidget;
use crate::chatwidget::ReplayKind;
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
use crate::status::format_estimated_usd_micros;
@@ -21,8 +22,9 @@ use pretty_assertions::assert_eq;
use std::time::Duration;
use std::time::Instant;
use tokio::sync::mpsc::UnboundedReceiver;
use uuid::Uuid;
fn thread_usage_request_id(events: &mut UnboundedReceiver<AppEvent>) -> u64 {
fn thread_usage_request_id(events: &mut UnboundedReceiver<AppEvent>) -> Uuid {
match events.try_recv() {
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
event => panic!("expected a thread usage request, got {event:?}"),
@@ -43,6 +45,30 @@ fn thread_usage(
}
}
async fn widget_with_pending_billing_read(thread_id: ThreadId) -> (ChatWidget, Uuid) {
let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
chat.thread_id = Some(thread_id);
chat.has_chatgpt_account = true;
chat.has_codex_backend_auth = true;
chat.plan_type = Some(PlanType::Business);
chat.ensure_thread_usage_requested();
let request_id = thread_usage_request_id(&mut events);
(chat, request_id)
}
#[tokio::test]
async fn replacement_widget_rejects_a_previous_widgets_read_for_the_same_thread() {
let thread = ThreadId::new();
let (_, old_request) = widget_with_pending_billing_read(thread).await;
let (mut replacement, current) = widget_with_pending_billing_read(thread).await;
assert!(!replacement.finish_thread_usage_refresh(
thread,
old_request,
Ok(ThreadUsageOutcome::Disabled)
));
assert!(replacement.is_pending_thread_usage(thread, current));
}
#[test]
fn estimated_cost_formats_dollars_and_sub_cent_values() {
assert_eq!(
@@ -687,6 +713,13 @@ async fn billing_surfaces_render_for_every_supported_enterprise_plan() {
));
assert!(rx.try_recv().is_err(), "billing surfaces share a request");
let snapshot = serde_json::to_value(chat.statusline_state()).unwrap();
let expected_cost = if plan_type == PlanType::Ent26 {
serde_json::json!({"total_cost_usd": 0.75})
} else {
serde_json::json!({"total_cost_usd": 0.21, "total_credits": 5.2})
};
assert_eq!(snapshot["thread"]["usage"]["cost"], expected_cost);
if plan_type == PlanType::Ent26 {
insta::assert_snapshot!(chat.status_line_text().unwrap(), @"~$0.75");
insta::assert_snapshot!(chat.last_terminal_title.as_ref().unwrap(), @"~$0.75");