Show native dollar thread usage for ENT26 plans (#57251)

## Why

ENT26 plans are billed directly in dollars, so credit estimates and credit-weighted breakdowns do not describe their thread usage.

## What changed

- Expose optional `nativeUsageUsdMicros` in app-server `ThreadUsage` responses and generated schemas and SDK types. Missing native usage means unknown, not zero.
- Enable ENT26 usage fetching and display native dollar amounts in `/status`, the status line, terminal titles, and the agents overview. Hide credits and omit credit-weighted `/status` breakdowns for these plans.
- Use native dollar amounts when tracking post-turn usage settlement for ENT26 plans.

## Testing

Add coverage for native usage forwarding, ENT26 billing displays, and settlement polling with stale, missing, or zero dollar amounts.

GitOrigin-RevId: ca8ea98141a66bdf0a27feb9f58f67822b821f5f
This commit is contained in:
jif
2026-10-26 20:54:35 +00:00
committed by copyberry
parent 3ad642e520
commit f358635d8e
26 changed files with 271 additions and 58 deletions
@@ -25899,6 +25899,14 @@
},
"type": "array"
},
"nativeUsageUsdMicros": {
"description": "Native dollar usage, independent of credit estimates. None means unknown, not zero.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"threadId": {
"type": "string"
}
@@ -23613,6 +23613,14 @@
},
"type": "array"
},
"nativeUsageUsdMicros": {
"description": "Native dollar usage, independent of credit estimates. None means unknown, not zero.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"threadId": {
"type": "string"
}
@@ -76,6 +76,14 @@
},
"type": "array"
},
"nativeUsageUsdMicros": {
"description": "Native dollar usage, independent of credit estimates. None means unknown, not zero.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"threadId": {
"type": "string"
}
@@ -3,4 +3,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadUsageBreakdownGroup } from "./ThreadUsageBreakdownGroup";
export type ThreadUsage = { threadId: string, estimatedUsageCreditsMicros: bigint, estimatedUsageUsdMicros: bigint | null, groups: Array<ThreadUsageBreakdownGroup>, };
export type ThreadUsage = { threadId: string, estimatedUsageCreditsMicros: bigint, estimatedUsageUsdMicros: bigint | null,
/**
* Native dollar usage, independent of credit estimates. None means unknown, not zero.
*/
nativeUsageUsdMicros?: bigint | null, groups: Array<ThreadUsageBreakdownGroup>, };
@@ -2409,6 +2409,8 @@ mod tests {
let legacy_optional_nullable_field = (path
== Path::new("v2/GetAccountTokenUsageResponse.ts")
&& field_prefix.trim() == "threadUsage?")
|| (path == Path::new("v2/ThreadUsage.ts")
&& field_prefix.trim() == "nativeUsageUsdMicros?")
|| (path == Path::new("v2/McpServerOauthLoginCompletedNotification.ts")
&& field_prefix.trim() == "loginId?")
|| (path == Path::new("v2/MisalignmentErrorDetails.ts")
@@ -1,3 +1,5 @@
//! Thread billing amounts, keeping native dollars separate from credit estimates.
use crate::JsonSchema;
use crate::TS;
use serde::Deserialize;
@@ -10,6 +12,9 @@ pub struct ThreadUsage {
pub thread_id: String,
pub estimated_usage_credits_micros: i64,
pub estimated_usage_usd_micros: Option<i64>,
/// Native dollar usage, independent of credit estimates. None means unknown, not zero.
#[ts(optional, as = "Option<Option<i64>>")]
pub native_usage_usd_micros: Option<i64>,
pub groups: Vec<ThreadUsageBreakdownGroup>,
}
@@ -1295,6 +1295,7 @@ impl AccountRequestProcessor {
thread_id: usage.thread_id,
estimated_usage_credits_micros: usage.estimated_usage_credits_micros,
estimated_usage_usd_micros: usage.estimated_usage_usd_micros,
native_usage_usd_micros: usage.native_usage_usd_micros,
groups: usage
.groups
.into_iter()
@@ -103,6 +103,7 @@ async fn account_thread_usage_uses_active_workspace_and_canonical_thread_ids() -
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: None,
native_usage_usd_micros: None,
groups: vec![ThreadUsageBreakdownGroup {
model: Some("gpt-5.4".to_string()),
reasoning_effort: Some("high".to_string()),
@@ -132,7 +133,7 @@ async fn account_thread_usage_supports_externally_managed_authentication() -> Re
let access_token = encode_id_token(
&ChatGptIdTokenClaims::new()
.email("external@example.com")
.plan_type("business")
.plan_type("ent26")
.chatgpt_account_id("external-workspace"),
)?;
Mock::given(method("GET"))
@@ -149,7 +150,7 @@ async fn account_thread_usage_supports_externally_managed_authentication() -> Re
.send_chatgpt_auth_tokens_login_request(
access_token.clone(),
"external-workspace".to_string(),
Some("business".to_string()),
Some("ent26".to_string()),
)
.await?;
let login: LoginAccountResponse =
@@ -164,8 +165,9 @@ async fn account_thread_usage_supports_externally_managed_authentication() -> Re
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
"threads": [{
"thread_id": thread_id,
"estimated_usage_credits_micros": 21_000_000,
"estimated_usage_usd_micros": 840_000
"estimated_usage_credits_micros": 0,
"estimated_usage_usd_micros": 0,
"native_usage_usd_micros": 750_000
}]
})))
.expect(/*r*/ 1)
@@ -181,8 +183,9 @@ async fn account_thread_usage_supports_externally_managed_authentication() -> Re
response.thread_usage,
Some(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 21_000_000,
estimated_usage_usd_micros: Some(840_000),
estimated_usage_credits_micros: 0,
estimated_usage_usd_micros: Some(0),
native_usage_usd_micros: Some(750_000),
groups: Vec::new(),
})
);
@@ -224,7 +224,12 @@ impl App {
usage_lines: ThreadId::from_string(&root.id)
.ok()
.and_then(|id| self.agents_overview.usage.get(&id))
.map(super::agents_overview_usage::usage_lines)
.map(|usage| {
super::agents_overview_usage::usage_lines(
usage,
self.chat_widget.current_plan_type(),
)
})
.unwrap_or_default(),
last_message,
}
+10 -6
View File
@@ -12,6 +12,7 @@ use crate::chatwidget::ThreadUsageOutcome;
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::tui::FrameRequester;
use codex_app_server_protocol::ThreadUsage;
use codex_app_server_protocol::ThreadUsageBreakdownGroup;
@@ -46,7 +47,8 @@ impl App {
|| !matches!(
self.chat_widget.current_plan_type(),
Some(
PlanType::Business
PlanType::Ent26
| PlanType::Business
| PlanType::EnterpriseCbpUsageBased
| PlanType::EnterpriseCbpAutomation
)
@@ -144,7 +146,10 @@ impl App {
}
}
pub(super) fn usage_lines(usage: &AgentsOverviewUsage) -> Vec<Line<'static>> {
pub(super) fn usage_lines(
usage: &AgentsOverviewUsage,
plan_type: Option<PlanType>,
) -> Vec<Line<'static>> {
let sum = |count: fn(&ThreadUsageBreakdownGroup) -> Option<i64>| {
let groups = &usage.estimate.as_ref()?.groups;
if groups.is_empty() {
@@ -179,15 +184,14 @@ pub(super) fn usage_lines(usage: &AgentsOverviewUsage) -> Vec<Line<'static>> {
}
if let Some(estimate) = &usage.estimate {
let mut values = Vec::new();
if estimate.estimated_usage_credits_micros >= 0 {
if plan_type != Some(PlanType::Ent26) && estimate.estimated_usage_credits_micros >= 0 {
values.push(format!(
"{} credits",
format_credit_micros(estimate.estimated_usage_credits_micros)
));
}
if let Some(cost) = estimate
.estimated_usage_usd_micros
.and_then(format_estimated_usd_micros)
if let Some(cost) =
thread_cost_micros(estimate, plan_type).and_then(format_estimated_usd_micros)
{
values.push(cost);
}
@@ -199,6 +199,47 @@ async fn selected_usage_is_cached_and_account_changes_discard_old_results() -> R
assert_eq!(app.agents_overview.pending_usage, None);
assert!(!app.agents_overview.usage_disabled);
}
app.chat_widget.update_account_state(
/*status_account_display*/ None,
Some(PlanType::Ent26),
/*has_chatgpt_account*/ false,
/*has_codex_backend_auth*/ true,
);
let view = app.agents_overview_view(threads.to_vec(), Some(selected));
app.agents_overview.visible_thread_ids = view.thread_ids();
app.chat_widget.show_bottom_pane_view(Box::new(view));
app.refresh_agents_overview_usage(&app_server, tui.frame_requester());
let (thread_id, request_id) = app
.agents_overview
.pending_usage
.expect("request ENT26 usage");
assert_eq!(thread_id, selected);
let mut native_usage = expected.clone();
native_usage.estimated_usage_credits_micros = 0;
native_usage.estimated_usage_usd_micros = Some(0);
native_usage.native_usage_usd_micros = Some(750_000);
app.finish_agents_overview_usage(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(native_usage)),
);
let view = app.agents_overview_view(threads.to_vec(), Some(selected));
let rendered = view
.rows
.iter()
.find(|row| row.thread_id == selected)
.unwrap()
.details
.usage_lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(rendered, @"
Tokens: 12K in · 3K out
Est. usage: ~$0.75
");
app.agents_overview.usage_disabled = true;
app.agents_overview.pending_usage = Some((thread_id, request_id));
app.app_server_target = AppServerTarget::LocalDaemon {
@@ -231,7 +272,7 @@ fn incomplete_billing_groups_do_not_display_partial_token_totals() {
.unwrap();
let mut usage = AgentsOverviewUsage::default();
usage.estimate = Some(estimate);
let text = usage_lines(&usage)
let text = usage_lines(&usage, Some(PlanType::Business))
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>();
+6 -4
View File
@@ -1428,8 +1428,9 @@ mod tests {
serde_json::json!({
"threads": [{
"thread_id": thread_id.to_string(),
"estimated_usage_credits_micros": 46_000_000,
"estimated_usage_usd_micros": 1_820_000
"estimated_usage_credits_micros": 0,
"estimated_usage_usd_micros": 0,
"native_usage_usd_micros": 750_000
}]
}),
))
@@ -1442,9 +1443,10 @@ mod tests {
.await
.expect("read thread usage through the authenticated app server"),
ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: Some(750_000),
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
estimated_usage_credits_micros: 0,
estimated_usage_usd_micros: Some(0),
groups: Vec::new(),
})
);
@@ -56,6 +56,7 @@ fn successful_thread_usage(thread_id: ThreadId, request_id: u64) -> AppEvent {
thread_id,
request_id,
result: Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -5,6 +5,7 @@
//! history-facing `/status` surface.
use super::*;
use crate::status::thread_cost_micros;
impl ChatWidget {
/// Update the status indicator header and details.
@@ -385,7 +386,7 @@ impl ChatWidget {
if self
.estimated_thread_usage()
.is_some_and(|usage| usage.estimated_usage_usd_micros.is_none())
.is_some_and(|usage| thread_cost_micros(usage, self.plan_type).is_none())
{
preview_data.suppress_placeholder(StatusSurfacePreviewItem::EstimatedThreadCost);
}
@@ -13,6 +13,7 @@ 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;
@@ -807,11 +808,12 @@ impl ChatWidget {
}),
StatusLineItem::ThreadCredits => self
.estimated_thread_usage()
.filter(|_| self.plan_type != Some(PlanType::Ent26))
.map(|usage| usage.estimated_usage_credits_micros)
.map(|credits| format!("{} credits", format_credit_micros(credits))),
StatusLineItem::EstimatedThreadCost => self
.estimated_thread_usage()
.and_then(|usage| usage.estimated_usage_usd_micros)
.and_then(|usage| thread_cost_micros(usage, self.plan_type))
.and_then(format_estimated_usd_micros),
StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()),
StatusLineItem::FastMode => self
@@ -3117,6 +3117,7 @@ async fn status_line_estimated_thread_cost_fetches_and_renders_backend_estimate(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -3153,6 +3154,7 @@ async fn status_line_thread_credits_fetches_and_renders_fractional_credits() {
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 5_240_000,
estimated_usage_usd_micros: Some(210_000),
@@ -3186,6 +3188,7 @@ async fn status_line_thread_credits_and_cost_share_one_backend_request() {
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 5_200_000,
estimated_usage_usd_micros: Some(210_000),
@@ -3220,6 +3223,7 @@ async fn status_line_thread_credits_remain_visible_when_usd_is_unavailable() {
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 5_200_000,
estimated_usage_usd_micros: None,
@@ -3262,6 +3266,7 @@ async fn terminal_title_thread_usage_fetches_and_renders_without_status_line() {
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 5_200_000,
estimated_usage_usd_micros: Some(210_000),
@@ -3299,6 +3304,7 @@ async fn terminal_title_thread_credits_remain_visible_when_usd_is_unavailable()
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 5_200_000,
estimated_usage_usd_micros: None,
@@ -3336,6 +3342,7 @@ async fn terminal_title_and_status_line_share_one_thread_usage_request() {
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 5_200_000,
estimated_usage_usd_micros: Some(210_000),
@@ -3409,6 +3416,7 @@ async fn status_line_estimated_thread_cost_preserves_cached_amount_during_settle
thread_id,
initial_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -3425,6 +3433,7 @@ async fn status_line_estimated_thread_cost_preserves_cached_amount_during_settle
thread_id,
settlement_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 0,
estimated_usage_usd_micros: Some(0),
@@ -3457,6 +3466,7 @@ async fn completed_turn_refreshes_estimated_thread_cost() {
thread_id,
initial_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -3481,6 +3491,7 @@ async fn completed_turn_refreshes_estimated_thread_cost() {
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(2_100_000),
@@ -3509,6 +3520,7 @@ async fn completed_turn_refreshes_credits_only_terminal_title() {
thread_id,
initial_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 5_200_000,
estimated_usage_usd_micros: None,
@@ -3533,6 +3545,7 @@ async fn completed_turn_refreshes_credits_only_terminal_title() {
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 6_200_000,
estimated_usage_usd_micros: None,
@@ -3560,6 +3573,7 @@ async fn interrupted_turn_refreshes_estimated_thread_usage() {
thread_id,
initial_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -3601,6 +3615,7 @@ async fn failed_turn_refreshes_estimated_thread_usage() {
thread_id,
initial_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -3652,6 +3667,7 @@ async fn status_line_estimated_thread_cost_rejects_stale_thread_completions() {
previous_thread_id,
previous_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: previous_thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -3664,6 +3680,7 @@ async fn status_line_estimated_thread_cost_rejects_stale_thread_completions() {
active_thread_id,
active_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: active_thread_id.to_string(),
estimated_usage_credits_micros: 1_000_000,
estimated_usage_usd_micros: Some(400),
@@ -3698,6 +3715,7 @@ async fn status_line_estimated_thread_cost_footer_snapshot() {
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 5_200_000,
estimated_usage_usd_micros: Some(210_000),
@@ -243,6 +243,7 @@ async fn status_command_renders_immediately_and_updates_first_card_without_statu
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -302,10 +303,9 @@ async fn status_command_renders_immediately_and_updates_first_card_without_statu
}
#[tokio::test]
async fn status_command_omits_thread_usage_for_non_cbp_enterprise_plans() {
async fn status_command_omits_thread_usage_for_unsupported_plans() {
for plan_type in [
Some(PlanType::Enterprise),
Some(PlanType::Ent26),
Some(PlanType::Pro),
Some(PlanType::Edu),
Some(PlanType::SelfServeBusinessUsageBased),
@@ -373,6 +373,7 @@ async fn status_command_stays_visible_and_updates_after_thread_usage_retry() {
thread_id,
retry_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -410,6 +411,7 @@ async fn status_command_renders_credits_and_breakdowns_without_usd_estimate() {
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: None,
@@ -475,6 +477,7 @@ async fn status_command_drops_stale_usd_when_updated_usage_has_only_credits() {
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 40_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -500,6 +503,7 @@ async fn status_command_drops_stale_usd_when_updated_usage_has_only_credits() {
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: None,
@@ -585,6 +589,7 @@ async fn status_command_remains_visible_when_account_changes_during_usage_refres
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -617,6 +622,7 @@ async fn status_command_remains_visible_when_thread_changes_during_usage_refresh
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -304,6 +304,7 @@ async fn status_surface_preview_lines_thread_usage_snapshot() {
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 5_200_000,
estimated_usage_usd_micros: Some(210_000),
@@ -351,6 +352,7 @@ async fn status_surface_thread_usage_previews_omit_unavailable_usd_estimates() {
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 5_200_000,
estimated_usage_usd_micros: None,
+10 -7
View File
@@ -9,6 +9,7 @@ use super::StatusLineItem;
use super::TerminalTitleItem;
use super::ThreadId;
use crate::status::StatusHistoryHandle;
use crate::status::thread_cost_micros;
use codex_app_server_protocol::ThreadUsage;
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -98,7 +99,7 @@ impl ChatWidget {
.thread_usage
.estimate
.as_ref()
.and_then(|estimate| estimate.estimated_usage_usd_micros);
.and_then(|estimate| thread_cost_micros(estimate, self.plan_type));
self.schedule_next_thread_usage_settlement();
self.request_thread_usage();
}
@@ -252,7 +253,7 @@ impl ChatWidget {
usage.groups.clone_from(&previous_usage.groups);
}
let estimated_credits_micros = usage.estimated_usage_credits_micros;
let estimated_usd_micros = usage.estimated_usage_usd_micros;
let estimated_usd_micros = thread_cost_micros(&usage, self.plan_type);
self.thread_usage.estimate = Some(usage);
if let Some(baseline) = self.thread_usage.settlement_baseline_credits_micros {
if !post_turn_request {
@@ -268,10 +269,10 @@ impl ChatWidget {
} else {
let mut selected = self.selected_thread_usage();
if !self.thread_usage.status_history_handles.is_empty() {
selected.credits = true;
selected.estimated_cost |=
self.thread_usage.settlement_baseline_usd_micros.is_some()
|| estimated_usd_micros.is_some();
selected.credits = self.plan_type != Some(PlanType::Ent26);
selected.estimated_cost |= self.plan_type == Some(PlanType::Ent26)
|| self.thread_usage.settlement_baseline_usd_micros.is_some()
|| estimated_usd_micros.is_some();
}
let credits_settled =
!selected.credits || estimated_credits_micros > baseline;
@@ -361,7 +362,8 @@ impl ChatWidget {
&& matches!(
self.plan_type,
Some(
PlanType::Business
PlanType::Ent26
| PlanType::Business
| PlanType::EnterpriseCbpUsageBased
| PlanType::EnterpriseCbpAutomation
)
@@ -389,6 +391,7 @@ impl ChatWidget {
Ok(_) | Err(_) => {}
}
}
selected.credits &= self.plan_type != Some(PlanType::Ent26);
selected
}
@@ -35,6 +35,7 @@ fn thread_usage(
estimated_usage_usd_micros: Option<i64>,
) -> ThreadUsage {
ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros,
estimated_usage_usd_micros,
@@ -472,27 +473,44 @@ async fn transient_zero_credits_preserves_credits_only_estimates() {
#[tokio::test]
async fn cost_settlement_waits_for_new_or_missing_usd_estimates() {
for (previous_usd_micros, stale_usd_micros) in [
(Some(1_820_000), Some(1_820_000)),
(None, None),
(None, Some(0)),
for (plan_type, previous_usd_micros, stale_usd_micros) in [
(PlanType::Business, Some(1_820_000), Some(1_820_000)),
(PlanType::Business, None, None),
(PlanType::Business, None, Some(0)),
(PlanType::Ent26, Some(1_820_000), Some(1_820_000)),
(PlanType::Ent26, None, None),
(PlanType::Ent26, None, Some(0)),
(PlanType::Ent26, Some(1_820_000), Some(0)),
] {
let (mut chat, _sender, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await;
let thread_id = ThreadId::new();
let [previous_usage, stale_usage, settled_usage] = [
(40_000_000, previous_usd_micros),
(50_000_000, stale_usd_micros),
(50_000_000, Some(2_100_000)),
]
.map(|(credits, cost)| {
let mut usage = thread_usage(thread_id, credits, cost);
usage.native_usage_usd_micros = cost;
if plan_type == PlanType::Ent26 {
usage.estimated_usage_credits_micros = 0;
usage.estimated_usage_usd_micros = Some(0);
}
usage
});
let previous_credits_micros = previous_usage.estimated_usage_credits_micros;
chat.thread_id = Some(thread_id);
chat.has_codex_backend_auth = true;
chat.plan_type = Some(PlanType::Business);
chat.plan_type = Some(plan_type);
chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]);
chat.local_settings.tui.terminal_title =
(plan_type == PlanType::Ent26).then(|| vec!["thread-credits".to_string()]);
chat.refresh_status_line();
let request_id = thread_usage_request_id(&mut rx);
assert!(chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(thread_usage(
thread_id,
/*estimated_usage_credits_micros*/ 40_000_000,
previous_usd_micros,
))),
Ok(ThreadUsageOutcome::Available(previous_usage)),
));
chat.refresh_thread_usage_after_turn();
@@ -500,15 +518,17 @@ async fn cost_settlement_waits_for_new_or_missing_usd_estimates() {
assert!(chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(thread_usage(
thread_id,
/*estimated_usage_credits_micros*/ 50_000_000,
stale_usd_micros,
))),
Ok(ThreadUsageOutcome::Available(stale_usage)),
));
if plan_type == PlanType::Ent26 {
assert_eq!(
chat.status_line_text(),
stale_usd_micros.and_then(format_estimated_usd_micros)
);
}
assert_eq!(
chat.thread_usage.settlement_baseline_credits_micros,
Some(40_000_000)
Some(previous_credits_micros)
);
assert_eq!(
chat.thread_usage.settlement_baseline_usd_micros,
@@ -522,11 +542,7 @@ async fn cost_settlement_waits_for_new_or_missing_usd_estimates() {
assert!(chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(thread_usage(
thread_id,
/*estimated_usage_credits_micros*/ 50_000_000,
Some(2_100_000),
))),
Ok(ThreadUsageOutcome::Available(settled_usage)),
));
assert_eq!(chat.status_line_text(), Some("~$2.10".to_string()));
@@ -635,6 +651,7 @@ async fn billing_surfaces_render_for_every_supported_enterprise_plan() {
PlanType::Business,
PlanType::EnterpriseCbpUsageBased,
PlanType::EnterpriseCbpAutomation,
PlanType::Ent26,
] {
let (mut chat, _sender, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await;
let thread_id = ThreadId::new();
@@ -651,13 +668,45 @@ async fn billing_surfaces_render_for_every_supported_enterprise_plan() {
assert!(chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(thread_usage(
thread_id,
/*estimated_usage_credits_micros*/ 5_200_000,
Some(210_000),
))),
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: Some(750_000),
..thread_usage(
thread_id,
if plan_type == PlanType::Ent26 {
0
} else {
5_200_000
},
Some(if plan_type == PlanType::Ent26 {
0
} else {
210_000
}),
)
})),
));
assert!(rx.try_recv().is_err(), "billing surfaces share a request");
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");
chat.add_status_output(
/*refreshing_rate_limits*/ false, /*request_id*/ None,
);
let status_cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
event => panic!("expected /status card, got {event:?}"),
};
let usage_row = status_cell
.display_lines(/*width*/ 90)
.into_iter()
.map(|line| line.to_string())
.find(|line| line.contains("Thread usage:"))
.unwrap();
insta::assert_snapshot!(usage_row, @" Thread usage: ~$0.75");
continue;
}
assert_eq!(
chat.status_line_text(),
Some("5.2 credits · ~$0.21".to_string())
@@ -666,7 +715,6 @@ async fn billing_surfaces_render_for_every_supported_enterprise_plan() {
chat.last_terminal_title,
Some("5.2 credits | ~$0.21".to_string())
);
assert!(rx.try_recv().is_err(), "billing surfaces share a request");
}
}
@@ -726,6 +774,7 @@ async fn unchanged_thread_usage_has_bounded_settlement_refreshes() {
thread_id,
initial_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -745,6 +794,7 @@ async fn unchanged_thread_usage_has_bounded_settlement_refreshes() {
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -783,6 +833,7 @@ async fn early_draw_rearms_thread_usage_settlement_after_immediate_redraw() {
event => panic!("expected initial thread usage request, got {event:?}"),
};
let usage = ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -848,6 +899,7 @@ async fn thread_usage_settlement_requires_request_started_after_turn_completion(
thread_id,
initial_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 40_000_000,
estimated_usage_usd_micros: Some(1_600_000),
@@ -872,6 +924,7 @@ async fn thread_usage_settlement_requires_request_started_after_turn_completion(
thread_id,
pre_turn_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -892,6 +945,7 @@ async fn thread_usage_settlement_requires_request_started_after_turn_completion(
thread_id,
post_turn_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -910,6 +964,7 @@ async fn thread_usage_settlement_requires_request_started_after_turn_completion(
thread_id,
settlement_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(2_100_000),
@@ -939,6 +994,7 @@ async fn deselecting_billing_surfaces_cancels_polling_and_preserves_status_cache
thread_id,
initial_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -981,6 +1037,7 @@ async fn deselecting_billing_surfaces_cancels_polling_and_preserves_status_cache
assert_eq!(
chat.estimated_thread_usage(),
Some(&ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
@@ -991,6 +1048,7 @@ async fn deselecting_billing_surfaces_cancels_polling_and_preserves_status_cache
thread_id,
settlement_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
native_usage_usd_micros: None,
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(2_100_000),
+5 -1
View File
@@ -140,6 +140,7 @@ struct StatusHistoryCell {
token_usage: StatusTokenUsageData,
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
thread_usage: StatusThreadUsage,
plan_type: Option<PlanType>,
}
#[cfg(test)]
@@ -399,6 +400,7 @@ impl StatusHistoryCell {
agents_summary,
rate_limit_state,
thread_usage,
plan_type: _plan_type,
}
}
@@ -862,7 +864,9 @@ impl StatusHistoryCell {
}
lines.extend(self.rate_limit_lines(&rate_limit_state, available_width, &formatter));
let thread_usage_lines = self.thread_usage.lines(&formatter, value_width);
let thread_usage_lines = self
.thread_usage
.lines(&formatter, value_width, self.plan_type);
if !thread_usage_lines.is_empty() {
lines.push(Line::from(Vec::<Span<'static>>::new()));
lines.extend(thread_usage_lines);
+1
View File
@@ -33,6 +33,7 @@ pub(crate) use rate_limits::rate_limit_snapshot_display;
pub(crate) use rate_limits::rate_limit_snapshot_display_for_limit;
pub(crate) use thread_usage::format_credit_micros;
pub(crate) use thread_usage::format_estimated_usd_micros;
pub(crate) use thread_usage::thread_cost_micros;
#[cfg(test)]
mod tests;
+19
View File
@@ -7,6 +7,7 @@ use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
use codex_app_server_protocol::ThreadUsage;
use codex_app_server_protocol::ThreadUsageBreakdownGroup;
use codex_protocol::account::PlanType;
use ratatui::prelude::Line;
use ratatui::style::Stylize;
use std::collections::BTreeMap;
@@ -32,6 +33,15 @@ const REASONING_LABEL: &str = " Reasoning";
const SPEED_LABEL: &str = " Speed";
const BILLED_TOKENS_LABEL: &str = " Billed tokens";
/// ENT26 is billed directly in dollars; missing native usage is not a zero estimate.
pub(crate) fn thread_cost_micros(usage: &ThreadUsage, plan_type: Option<PlanType>) -> Option<i64> {
if plan_type == Some(PlanType::Ent26) {
usage.native_usage_usd_micros
} else {
usage.estimated_usage_usd_micros
}
}
/// Shared state updates a committed `/status` card when its asynchronous estimate arrives.
#[derive(Clone, Debug, Default)]
pub(crate) struct StatusThreadUsage {
@@ -87,6 +97,7 @@ impl StatusThreadUsage {
&self,
formatter: &FieldFormatter,
value_width: usize,
plan_type: Option<PlanType>,
) -> Vec<Line<'static>> {
#[expect(clippy::expect_used)]
let stored_estimate = self
@@ -97,6 +108,14 @@ impl StatusThreadUsage {
return Vec::new();
};
if plan_type == Some(PlanType::Ent26) {
// Credit-weighted breakdowns do not describe native dollar usage.
return thread_cost_micros(estimate, plan_type)
.and_then(format_estimated_usd_micros)
.map(|cost| vec![formatter.line("Thread usage", vec![cost.into()])])
.unwrap_or_default();
}
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
@@ -10784,6 +10784,13 @@ class ThreadUsage(BaseModel):
estimated_usage_credits_micros: Annotated[int, Field(alias="estimatedUsageCreditsMicros")]
estimated_usage_usd_micros: Annotated[int | None, Field(alias="estimatedUsageUsdMicros")] = None
groups: list[ThreadUsageBreakdownGroup]
native_usage_usd_micros: Annotated[
int | None,
Field(
alias="nativeUsageUsdMicros",
description="Native dollar usage, independent of credit estimates. None means unknown, not zero.",
),
] = None
thread_id: Annotated[str, Field(alias="threadId")]