mirror of
https://github.com/openai/codex.git
synced 2026-11-08 00:36:31 +00:00
Show estimated thread usage in /status (#38281)
## What changed - Extend `account/usage/read` with an optional `threadId` request and a backward-compatible `threadUsage` response containing estimated credits, optional USD cost, and model, reasoning, speed, and token breakdowns. - Fetch thread usage for eligible Business and Enterprise plans when `/status` is opened, then update the rendered status card asynchronously. - Preserve terminal scrollback when replacing a visible status-card tail, and append a refreshed card when the original tail can no longer be updated. ## Testing - Add app-server coverage for authentication, canonical thread IDs, unavailable billing routes, and malformed IDs. - Add TUI coverage for usage formatting, stale responses, retries, history updates, terminal reflow, and scrollback preservation. GitOrigin-RevId: 49300914b3fba917c0f08e8595363e04e5ba6d37
This commit is contained in:
1 parent
27a98dde4d
commit
f1a1fce26a
55 files changed
+2903
-60
No files matched your search
@@ -47,6 +47,7 @@ use super::rate_limits::compose_rate_limit_data_many;
|
||||
use super::rate_limits::format_status_limit_summary;
|
||||
use super::rate_limits::render_status_limit_progress_bar;
|
||||
use super::remote_connection::RemoteConnectionStatus;
|
||||
use super::thread_usage::StatusThreadUsage;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::adaptive_wrap_lines;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
@@ -79,9 +80,14 @@ struct StatusRateLimitState {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct StatusHistoryHandle {
|
||||
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
|
||||
thread_usage: StatusThreadUsage,
|
||||
}
|
||||
|
||||
impl StatusHistoryHandle {
|
||||
pub(crate) fn reserve_thread_usage_label_width(&self) {
|
||||
self.thread_usage.reserve_label_width();
|
||||
}
|
||||
|
||||
pub(crate) fn finish_rate_limit_refresh(
|
||||
&self,
|
||||
rate_limits: &[RateLimitSnapshotDisplay],
|
||||
@@ -100,6 +106,13 @@ impl StatusHistoryHandle {
|
||||
state.rate_limits = rate_limits;
|
||||
state.refreshing_rate_limits = false;
|
||||
}
|
||||
|
||||
pub(crate) fn set_thread_usage(
|
||||
&self,
|
||||
estimate: Option<codex_app_server_protocol::ThreadUsage>,
|
||||
) {
|
||||
self.thread_usage.set_estimate(estimate);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -119,6 +132,7 @@ struct StatusHistoryCell {
|
||||
forked_from: Option<String>,
|
||||
token_usage: StatusTokenUsageData,
|
||||
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
|
||||
thread_usage: StatusThreadUsage,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -350,6 +364,7 @@ impl StatusHistoryCell {
|
||||
refreshing_rate_limits,
|
||||
}));
|
||||
let agents_summary = Arc::new(RwLock::new(agents_summary));
|
||||
let thread_usage = StatusThreadUsage::default();
|
||||
|
||||
(
|
||||
Self {
|
||||
@@ -368,8 +383,12 @@ impl StatusHistoryCell {
|
||||
token_usage,
|
||||
agents_summary,
|
||||
rate_limit_state: rate_limit_state.clone(),
|
||||
thread_usage: thread_usage.clone(),
|
||||
},
|
||||
StatusHistoryHandle {
|
||||
rate_limit_state,
|
||||
thread_usage,
|
||||
},
|
||||
StatusHistoryHandle { rate_limit_state },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -770,8 +789,8 @@ impl HistoryCell for StatusHistoryCell {
|
||||
if self.token_usage.context_window.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Context window");
|
||||
}
|
||||
|
||||
self.collect_rate_limit_labels(&rate_limit_state, &mut seen, &mut labels);
|
||||
self.thread_usage.push_labels(&mut labels, &mut seen);
|
||||
|
||||
let formatter = FieldFormatter::from_labels(labels.iter().map(String::as_str));
|
||||
let value_width = formatter.value_width(available_inner_width);
|
||||
@@ -860,6 +879,11 @@ impl HistoryCell for StatusHistoryCell {
|
||||
}
|
||||
|
||||
lines.extend(self.rate_limit_lines(&rate_limit_state, available_inner_width, &formatter));
|
||||
let thread_usage_lines = self.thread_usage.lines(&formatter, value_width);
|
||||
if !thread_usage_lines.is_empty() {
|
||||
lines.push(Line::from(Vec::<Span<'static>>::new()));
|
||||
lines.extend(thread_usage_lines);
|
||||
}
|
||||
|
||||
let content_width = lines.iter().map(line_width).max().unwrap_or(0);
|
||||
let inner_width = content_width.min(available_inner_width);
|
||||
|
||||
@@ -12,6 +12,7 @@ mod format;
|
||||
mod helpers;
|
||||
mod rate_limits;
|
||||
pub(crate) mod remote_connection;
|
||||
mod thread_usage;
|
||||
|
||||
pub(crate) use account::StatusAccountDisplay;
|
||||
pub(crate) use card::StatusHistoryHandle;
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Live-updating, thread-scoped billing details embedded in `/status` history cards.
|
||||
|
||||
use super::format::FieldFormatter;
|
||||
use super::format::push_label;
|
||||
use super::helpers::format_tokens_compact;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
use codex_app_server_protocol::ThreadUsage;
|
||||
use codex_app_server_protocol::ThreadUsageBreakdownGroup;
|
||||
use ratatui::prelude::Line;
|
||||
use ratatui::style::Stylize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
const REASONING_ORDER: [&str; 8] = [
|
||||
"None",
|
||||
"Minimal",
|
||||
"Light",
|
||||
"Medium",
|
||||
"High",
|
||||
"Extra High",
|
||||
"Max",
|
||||
"Ultra",
|
||||
];
|
||||
const SPEED_ORDER: [&str; 3] = ["Fast mode", "Ultrafast", "Standard"];
|
||||
const MODELS_LABEL: &str = " Models";
|
||||
const REASONING_LABEL: &str = " Reasoning";
|
||||
const SPEED_LABEL: &str = " Speed";
|
||||
const BILLED_TOKENS_LABEL: &str = " Billed tokens";
|
||||
|
||||
/// Shared state updates a committed `/status` card when its asynchronous estimate arrives.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct StatusThreadUsage {
|
||||
estimate: Arc<RwLock<Option<ThreadUsage>>>,
|
||||
reserve_label_width: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl StatusThreadUsage {
|
||||
pub(crate) fn reserve_label_width(&self) {
|
||||
self.reserve_label_width
|
||||
.store(/*val*/ true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn set_estimate(&self, estimate: Option<ThreadUsage>) {
|
||||
#[expect(clippy::expect_used)]
|
||||
let mut stored_estimate = self
|
||||
.estimate
|
||||
.write()
|
||||
.expect("status history thread-usage state poisoned");
|
||||
*stored_estimate = estimate;
|
||||
}
|
||||
|
||||
pub(crate) fn push_labels(&self, labels: &mut Vec<String>, seen: &mut BTreeSet<String>) {
|
||||
// Keep existing card rows stable when asynchronous billing details arrive. Reserving a
|
||||
// formatter label does not emit a loading placeholder or an empty billing row.
|
||||
if self.reserve_label_width.load(Ordering::Relaxed) {
|
||||
push_label(labels, seen, BILLED_TOKENS_LABEL);
|
||||
}
|
||||
#[expect(clippy::expect_used)]
|
||||
let stored_estimate = self
|
||||
.estimate
|
||||
.read()
|
||||
.expect("status history thread-usage state poisoned");
|
||||
let Some(estimate) = stored_estimate.as_ref() else {
|
||||
return;
|
||||
};
|
||||
push_label(labels, seen, "Thread usage");
|
||||
if !estimate.groups.is_empty() {
|
||||
push_label(labels, seen, MODELS_LABEL);
|
||||
push_label(labels, seen, REASONING_LABEL);
|
||||
push_label(labels, seen, SPEED_LABEL);
|
||||
if estimate
|
||||
.groups
|
||||
.iter()
|
||||
.any(|group| group.input_tokens.is_some() || group.output_tokens.is_some())
|
||||
{
|
||||
push_label(labels, seen, BILLED_TOKENS_LABEL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn lines(
|
||||
&self,
|
||||
formatter: &FieldFormatter,
|
||||
value_width: usize,
|
||||
) -> Vec<Line<'static>> {
|
||||
#[expect(clippy::expect_used)]
|
||||
let stored_estimate = self
|
||||
.estimate
|
||||
.read()
|
||||
.expect("status history thread-usage state poisoned");
|
||||
let Some(estimate) = stored_estimate.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let credits_micros = estimate.estimated_usage_credits_micros;
|
||||
let mut usage = vec![format!("{} credits", format_credit_micros(credits_micros)).into()];
|
||||
if let Some(cost) = estimate
|
||||
.estimated_usage_usd_micros
|
||||
.and_then(format_estimated_usd_micros)
|
||||
{
|
||||
usage.push(" · ".dim());
|
||||
usage.push(cost.into());
|
||||
}
|
||||
let mut lines = vec![formatter.line("Thread usage", usage)];
|
||||
|
||||
for (label, dimension) in [
|
||||
(MODELS_LABEL, BreakdownDimension::Model),
|
||||
(REASONING_LABEL, BreakdownDimension::Reasoning),
|
||||
(SPEED_LABEL, BreakdownDimension::Speed),
|
||||
] {
|
||||
let Some(value) = grouped_usage(&estimate.groups, dimension) else {
|
||||
continue;
|
||||
};
|
||||
let mut wrapped = word_wrap_lines(
|
||||
[Line::from(value)],
|
||||
RtOptions::new(value_width.max(/*other*/ 1)),
|
||||
)
|
||||
.into_iter();
|
||||
if let Some(first) = wrapped.next() {
|
||||
lines.push(formatter.line(label, first.spans));
|
||||
lines.extend(wrapped.map(|line| formatter.continuation(line.spans)));
|
||||
}
|
||||
}
|
||||
|
||||
let sum_tokens = |count: fn(&ThreadUsageBreakdownGroup) -> Option<i64>| {
|
||||
estimate
|
||||
.groups
|
||||
.iter()
|
||||
.try_fold(/*init*/ 0_i64, |total, group| {
|
||||
count(group).map(|tokens| total.saturating_add(tokens))
|
||||
})
|
||||
};
|
||||
let input_tokens = sum_tokens(|group| group.input_tokens);
|
||||
let cached_tokens = sum_tokens(|group| group.cached_input_tokens);
|
||||
let output_tokens = sum_tokens(|group| group.output_tokens);
|
||||
if !estimate.groups.is_empty() && (input_tokens.is_some() || output_tokens.is_some()) {
|
||||
let mut tokens = Vec::new();
|
||||
if let Some(input_tokens) = input_tokens {
|
||||
tokens.push(format!("{} input", format_tokens_compact(input_tokens)));
|
||||
if let Some(cached_tokens) = cached_tokens {
|
||||
tokens.push(format!("({} cached)", format_tokens_compact(cached_tokens)));
|
||||
}
|
||||
}
|
||||
if let Some(output_tokens) = output_tokens {
|
||||
if !tokens.is_empty() {
|
||||
tokens.push("+".to_string());
|
||||
}
|
||||
tokens.push(format!("{} output", format_tokens_compact(output_tokens)));
|
||||
}
|
||||
lines.push(formatter.line(BILLED_TOKENS_LABEL, vec![tokens.join(" ").into()]));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum BreakdownDimension {
|
||||
Model,
|
||||
Reasoning,
|
||||
Speed,
|
||||
}
|
||||
|
||||
fn grouped_usage(
|
||||
groups: &[ThreadUsageBreakdownGroup],
|
||||
dimension: BreakdownDimension,
|
||||
) -> Option<String> {
|
||||
let mut totals = BTreeMap::<String, i64>::new();
|
||||
for group in groups {
|
||||
if group.estimated_usage_credits_micros < 0 {
|
||||
continue;
|
||||
}
|
||||
let value = match dimension {
|
||||
BreakdownDimension::Model => group
|
||||
.model
|
||||
.as_deref()
|
||||
.map(format_model_display_name)
|
||||
.unwrap_or_else(|| "Other".to_string()),
|
||||
BreakdownDimension::Reasoning => match group.reasoning_effort.as_deref() {
|
||||
Some("none") => "None",
|
||||
Some("minimal") => "Minimal",
|
||||
Some("low") => "Light",
|
||||
Some("medium") => "Medium",
|
||||
Some("high") => "High",
|
||||
Some("xhigh") => "Extra High",
|
||||
Some("max") => "Max",
|
||||
Some("ultra") => "Ultra",
|
||||
Some(other) => other,
|
||||
None => "Other",
|
||||
}
|
||||
.to_string(),
|
||||
BreakdownDimension::Speed => match group.speed.as_deref() {
|
||||
Some("fast") => "Fast mode",
|
||||
Some("ultrafast") => "Ultrafast",
|
||||
Some("standard") => "Standard",
|
||||
Some(other) => other,
|
||||
None => "Other",
|
||||
}
|
||||
.to_string(),
|
||||
};
|
||||
let total = totals.entry(value).or_default();
|
||||
*total = total.saturating_add(group.estimated_usage_credits_micros);
|
||||
}
|
||||
if totals.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let total_credits_micros =
|
||||
totals.values().fold(
|
||||
/*init*/ 0_i64,
|
||||
|total, credits| total.saturating_add(*credits),
|
||||
);
|
||||
let mut totals = totals.into_iter().collect::<Vec<_>>();
|
||||
totals.sort_by(
|
||||
|(left_name, left_credits), (right_name, right_credits)| match dimension {
|
||||
BreakdownDimension::Model => right_credits
|
||||
.cmp(left_credits)
|
||||
.then_with(|| left_name.cmp(right_name)),
|
||||
BreakdownDimension::Reasoning => REASONING_ORDER
|
||||
.iter()
|
||||
.position(|value| value == left_name)
|
||||
.unwrap_or(usize::MAX)
|
||||
.cmp(
|
||||
&REASONING_ORDER
|
||||
.iter()
|
||||
.position(|value| value == right_name)
|
||||
.unwrap_or(usize::MAX),
|
||||
)
|
||||
.then_with(|| left_name.cmp(right_name)),
|
||||
BreakdownDimension::Speed => SPEED_ORDER
|
||||
.iter()
|
||||
.position(|value| value == left_name)
|
||||
.unwrap_or(usize::MAX)
|
||||
.cmp(
|
||||
&SPEED_ORDER
|
||||
.iter()
|
||||
.position(|value| value == right_name)
|
||||
.unwrap_or(usize::MAX),
|
||||
)
|
||||
.then_with(|| left_name.cmp(right_name)),
|
||||
},
|
||||
);
|
||||
Some(
|
||||
totals
|
||||
.into_iter()
|
||||
.map(|(name, credits)| {
|
||||
let percentage = if total_credits_micros <= 0 {
|
||||
0
|
||||
} else {
|
||||
(i128::from(credits) * 100 + i128::from(total_credits_micros) / 2)
|
||||
/ i128::from(total_credits_micros)
|
||||
};
|
||||
format!("{name} {percentage}%")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_model_display_name(model: &str) -> String {
|
||||
let Some((prefix, remainder)) = model.split_once('-') else {
|
||||
return model.to_string();
|
||||
};
|
||||
if !prefix.eq_ignore_ascii_case("gpt") || remainder.is_empty() {
|
||||
return model.to_string();
|
||||
}
|
||||
|
||||
let mut segments = remainder.split('-');
|
||||
let first = segments.next().unwrap_or_default();
|
||||
let mut display_name = format!("GPT-{first}");
|
||||
let numeric_model = first
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|character| character.is_ascii_digit());
|
||||
for segment in segments {
|
||||
display_name.push(if numeric_model { ' ' } else { '-' });
|
||||
if segment.eq_ignore_ascii_case("oai") {
|
||||
display_name.push_str("OAI");
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut characters = segment.chars();
|
||||
if let Some(first_character) = characters.next() {
|
||||
display_name.extend(first_character.to_uppercase());
|
||||
display_name.push_str(characters.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
display_name
|
||||
}
|
||||
|
||||
pub(crate) fn format_credit_micros(micros: i64) -> String {
|
||||
let micros = i128::from(micros.max(/*other*/ 0));
|
||||
let (unit_micros, suffix) = [
|
||||
(1_000_000_000_000_000_000_i128, "T"),
|
||||
(1_000_000_000_000_000_i128, "B"),
|
||||
(1_000_000_000_000_i128, "M"),
|
||||
(1_000_000_000_i128, "K"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|(unit, _)| micros >= *unit)
|
||||
.unwrap_or((1_000_000, ""));
|
||||
let tenths = (micros * 10 + unit_micros / 2) / unit_micros;
|
||||
let whole = tenths / 10;
|
||||
let fraction = tenths % 10;
|
||||
if fraction == 0 {
|
||||
format!("{whole}{suffix}")
|
||||
} else {
|
||||
format!("{whole}.{fraction}{suffix}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_estimated_usd_micros(micros: i64) -> Option<String> {
|
||||
if micros < 0 {
|
||||
return None;
|
||||
}
|
||||
if micros > 0 && micros < 100 {
|
||||
return Some(format!("~$0.{micros:06}"));
|
||||
}
|
||||
if micros > 0 && micros < 10_000 {
|
||||
let ten_thousandths = micros.saturating_add(/*rhs*/ 50) / 100;
|
||||
return Some(format!("~$0.{ten_thousandths:04}"));
|
||||
}
|
||||
|
||||
let cents = micros.saturating_add(/*rhs*/ 5_000) / 10_000;
|
||||
Some(format!("~${}.{:02}", cents / 100, cents % 100))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "thread_usage_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,70 @@
|
||||
use super::BreakdownDimension;
|
||||
use super::format_credit_micros;
|
||||
use super::format_model_display_name;
|
||||
use super::grouped_usage;
|
||||
use codex_app_server_protocol::ThreadUsageBreakdownGroup;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn thread_usage_breakdown_groups_aggregate_model_credit_percentages() {
|
||||
let group = ThreadUsageBreakdownGroup {
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
speed: Some("fast".to_string()),
|
||||
estimated_usage_credits_micros: 30_000_000,
|
||||
net_new_input_tokens: Some(60),
|
||||
cached_input_tokens: Some(10),
|
||||
input_tokens: Some(70),
|
||||
output_tokens: Some(20),
|
||||
total_tokens: Some(90),
|
||||
};
|
||||
let groups = vec![
|
||||
group.clone(),
|
||||
ThreadUsageBreakdownGroup {
|
||||
reasoning_effort: Some("medium".to_string()),
|
||||
speed: Some("standard".to_string()),
|
||||
estimated_usage_credits_micros: 10_000_000,
|
||||
..group.clone()
|
||||
},
|
||||
ThreadUsageBreakdownGroup {
|
||||
model: Some("gpt-5-mini".to_string()),
|
||||
reasoning_effort: Some("low".to_string()),
|
||||
speed: Some("standard".to_string()),
|
||||
estimated_usage_credits_micros: 10_000_000,
|
||||
..group
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
grouped_usage(&groups, BreakdownDimension::Model),
|
||||
Some("GPT-5.4 80%, GPT-5 Mini 20%".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
grouped_usage(&groups, BreakdownDimension::Reasoning),
|
||||
Some("Light 20%, Medium 20%, High 60%".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
grouped_usage(&groups, BreakdownDimension::Speed),
|
||||
Some("Fast mode 60%, Standard 40%".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_usage_credit_formatting_rounds_to_one_decimal_place() {
|
||||
assert_eq!(format_credit_micros(/*micros*/ 0), "0");
|
||||
assert_eq!(format_credit_micros(/*micros*/ 400_000), "0.4");
|
||||
assert_eq!(format_credit_micros(/*micros*/ 5_200_000), "5.2");
|
||||
assert_eq!(format_credit_micros(/*micros*/ 5_240_000), "5.2");
|
||||
assert_eq!(format_credit_micros(/*micros*/ 5_250_000), "5.3");
|
||||
assert_eq!(format_credit_micros(/*micros*/ 46_000_000), "46");
|
||||
assert_eq!(format_credit_micros(/*micros*/ 1_240_000_000), "1.2K");
|
||||
assert_eq!(format_credit_micros(/*micros*/ 12_400_000_000), "12.4K");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_usage_model_names_match_desktop_display_names() {
|
||||
assert_eq!(format_model_display_name("gpt-5.4"), "GPT-5.4");
|
||||
assert_eq!(format_model_display_name("gpt-5-mini"), "GPT-5 Mini");
|
||||
assert_eq!(format_model_display_name("gpt-5.6-sol"), "GPT-5.6 Sol");
|
||||
assert_eq!(format_model_display_name("mewfour"), "mewfour");
|
||||
}
|
||||
Reference in new issue
Block a user