diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index c185563d7c..3d72909115 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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" } diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index a5663a8c06..5505864acc 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json index a7e85aa8b4..dbfafb5dac 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json @@ -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" } diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 208990a258..841bde32dd 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 498a398ae2..581d6d0af4 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadUsage.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadUsage.ts index 1fcf444131..005f62ee6b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadUsage.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadUsage.ts @@ -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, }; +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, }; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index ebd08d62a1..6e1e5f48be 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -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") diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_usage.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_usage.rs index 249cfa969b..b48e35ecdd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_usage.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_usage.rs @@ -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, + /// Native dollar usage, independent of credit estimates. None means unknown, not zero. + #[ts(optional, as = "Option>")] + pub native_usage_usd_micros: Option, pub groups: Vec, } diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index cf4c6406bc..43946dc1ed 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -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() diff --git a/codex-rs/app-server/tests/suite/v2/account_thread_usage.rs b/codex-rs/app-server/tests/suite/v2/account_thread_usage.rs index 17e507ccf7..902dd6a553 100644 --- a/codex-rs/app-server/tests/suite/v2/account_thread_usage.rs +++ b/codex-rs/app-server/tests/suite/v2/account_thread_usage.rs @@ -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(), }) ); diff --git a/codex-rs/tui/src/app/agents_overview_details.rs b/codex-rs/tui/src/app/agents_overview_details.rs index 0214604643..5a8fa768c3 100644 --- a/codex-rs/tui/src/app/agents_overview_details.rs +++ b/codex-rs/tui/src/app/agents_overview_details.rs @@ -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, } diff --git a/codex-rs/tui/src/app/agents_overview_usage.rs b/codex-rs/tui/src/app/agents_overview_usage.rs index 1c0f41bca9..fe7f7f2313 100644 --- a/codex-rs/tui/src/app/agents_overview_usage.rs +++ b/codex-rs/tui/src/app/agents_overview_usage.rs @@ -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> { +pub(super) fn usage_lines( + usage: &AgentsOverviewUsage, + plan_type: Option, +) -> Vec> { let sum = |count: fn(&ThreadUsageBreakdownGroup) -> Option| { let groups = &usage.estimate.as_ref()?.groups; if groups.is_empty() { @@ -179,15 +184,14 @@ pub(super) fn usage_lines(usage: &AgentsOverviewUsage) -> Vec> { } 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); } diff --git a/codex-rs/tui/src/app/agents_overview_usage_tests.rs b/codex-rs/tui/src/app/agents_overview_usage_tests.rs index b8c321a304..af459f856d 100644 --- a/codex-rs/tui/src/app/agents_overview_usage_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_usage_tests.rs @@ -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::>() + .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::>(); diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 5133f7f96e..ad5f6e3d7b 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -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(), }) ); diff --git a/codex-rs/tui/src/app/tests/thread_usage.rs b/codex-rs/tui/src/app/tests/thread_usage.rs index 8d6d408de6..6a5ad7a79e 100644 --- a/codex-rs/tui/src/app/tests/thread_usage.rs +++ b/codex-rs/tui/src/app/tests/thread_usage.rs @@ -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), diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index a5d020c749..046c55bc19 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -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); } diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index d7340e5437..f455721809 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 00dab60b7d..fcb59879f3 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -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), diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index fb7a21c25c..b6daf7a335 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -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), diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 8b8416595c..a05f180af2 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget/thread_usage.rs b/codex-rs/tui/src/chatwidget/thread_usage.rs index 9907aa1c69..19ba15a93c 100644 --- a/codex-rs/tui/src/chatwidget/thread_usage.rs +++ b/codex-rs/tui/src/chatwidget/thread_usage.rs @@ -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 } diff --git a/codex-rs/tui/src/chatwidget/thread_usage_tests.rs b/codex-rs/tui/src/chatwidget/thread_usage_tests.rs index 64aeca360a..f0b525de10 100644 --- a/codex-rs/tui/src/chatwidget/thread_usage_tests.rs +++ b/codex-rs/tui/src/chatwidget/thread_usage_tests.rs @@ -35,6 +35,7 @@ fn thread_usage( estimated_usage_usd_micros: Option, ) -> 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), diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 65ef9f651d..44d45e4976 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -140,6 +140,7 @@ struct StatusHistoryCell { token_usage: StatusTokenUsageData, rate_limit_state: Arc>, thread_usage: StatusThreadUsage, + plan_type: Option, } #[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::>::new())); lines.extend(thread_usage_lines); diff --git a/codex-rs/tui/src/status/mod.rs b/codex-rs/tui/src/status/mod.rs index 8f46cfc0dd..a79c95da7f 100644 --- a/codex-rs/tui/src/status/mod.rs +++ b/codex-rs/tui/src/status/mod.rs @@ -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; diff --git a/codex-rs/tui/src/status/thread_usage.rs b/codex-rs/tui/src/status/thread_usage.rs index 60948689fd..ef419ad252 100644 --- a/codex-rs/tui/src/status/thread_usage.rs +++ b/codex-rs/tui/src/status/thread_usage.rs @@ -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) -> Option { + 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, ) -> Vec> { #[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 diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index fcd8484fed..0551659b29 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -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")]