From 3629508849fe7679ae6362ffa1541fb0ab9fb0cb Mon Sep 17 00:00:00 2001 From: asohrabi-oai Date: Fri, 2 Oct 2026 22:04:19 +0000 Subject: [PATCH] Preserve native USD amounts in thread usage responses (#50442) ## What changed Add optional `native_usage_usd_micros` fields to thread usage totals and breakdown groups, and carry backend values through the client alongside existing credit and USD estimates. Missing or null native amounts remain `None`, while explicit zero amounts remain `Some(0)`. ## Testing Add coverage for native totals and group amounts, mixed batches with legacy responses, and the distinction between missing, null, and zero native group amounts. GitOrigin-RevId: 53891fc95807138e2eccb99d4cda1520b857dba4 --- .../backend-client/src/client/thread_usage.rs | 8 +- .../src/client/thread_usage_tests.rs | 163 ++++++++++++++++++ codex-rs/tui/src/analytics/chats_tests.rs | 1 + codex-rs/tui/src/analytics/test_fixtures.rs | 2 + 4 files changed, 172 insertions(+), 2 deletions(-) diff --git a/codex-rs/backend-client/src/client/thread_usage.rs b/codex-rs/backend-client/src/client/thread_usage.rs index 6b7d8905c3..e7723b1e7b 100644 --- a/codex-rs/backend-client/src/client/thread_usage.rs +++ b/codex-rs/backend-client/src/client/thread_usage.rs @@ -1,4 +1,4 @@ -//! Authoritative estimated credit and dollar usage for bounded batches of Codex threads. +//! Backend credit estimates and native-dollar usage for bounded batches of Codex threads. use super::Client; use super::PathStyle; @@ -17,6 +17,7 @@ pub struct ThreadUsageBreakdownGroup { pub reasoning_effort: Option, pub speed: Option, pub estimated_usage_credits_micros: i64, + pub native_usage_usd_micros: Option, pub net_new_input_tokens: Option, pub cached_input_tokens: Option, pub input_tokens: Option, @@ -24,12 +25,13 @@ pub struct ThreadUsageBreakdownGroup { pub total_tokens: Option, } -/// Backend-estimated usage totals expressed in integer millionths. +/// Backend usage totals expressed in integer millionths of their respective units. #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] pub struct ThreadUsage { pub thread_id: String, pub estimated_usage_credits_micros: i64, pub estimated_usage_usd_micros: Option, + pub native_usage_usd_micros: Option, #[serde(default)] pub groups: Vec, } @@ -50,6 +52,7 @@ struct ThreadUsageEstimate { thread_id: String, estimated_usage_credits_micros: Option, estimated_usage_usd_micros: Option, + native_usage_usd_micros: Option, groups: Option>, } @@ -103,6 +106,7 @@ impl Client { thread_id: row.thread_id, estimated_usage_credits_micros: row.estimated_usage_credits_micros?, estimated_usage_usd_micros: row.estimated_usage_usd_micros, + native_usage_usd_micros: row.native_usage_usd_micros, groups: row.groups.unwrap_or_default(), }) }) diff --git a/codex-rs/backend-client/src/client/thread_usage_tests.rs b/codex-rs/backend-client/src/client/thread_usage_tests.rs index 938deab83c..1ad1151e82 100644 --- a/codex-rs/backend-client/src/client/thread_usage_tests.rs +++ b/codex-rs/backend-client/src/client/thread_usage_tests.rs @@ -41,11 +41,13 @@ async fn get_thread_usage_returns_requested_thread_totals() { "thread_id": "thread-123", "estimated_usage_credits_micros": 46_000_000, "estimated_usage_usd_micros": 1_820_000, + "native_usage_usd_micros": 3_250_000, "groups": [{ "model": "gpt-5.4", "reasoning_effort": "high", "speed": "fast", "estimated_usage_credits_micros": 46_000_000, + "native_usage_usd_micros": 3_250_000, "net_new_input_tokens": 80, "cached_input_tokens": 20, "input_tokens": 100, @@ -71,11 +73,13 @@ async fn get_thread_usage_returns_requested_thread_totals() { thread_id: "thread-123".to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: Some(1_820_000), + native_usage_usd_micros: Some(3_250_000), groups: vec![ThreadUsageBreakdownGroup { model: Some("gpt-5.4".to_string()), reasoning_effort: Some("high".to_string()), speed: Some("fast".to_string()), estimated_usage_credits_micros: 46_000_000, + native_usage_usd_micros: Some(3_250_000), net_new_input_tokens: Some(80), cached_input_tokens: Some(20), input_tokens: Some(100), @@ -115,6 +119,7 @@ async fn get_thread_usage_accepts_credits_without_usd_estimate() { thread_id: "thread-123".to_string(), estimated_usage_credits_micros: 46_000_000, estimated_usage_usd_micros: None, + native_usage_usd_micros: None, groups: Vec::new(), } ); @@ -198,3 +203,161 @@ async fn batch_usage_rejects_duplicate_and_unrequested_response_rows() { } } } + +#[test] +fn thread_usage_preserves_unknown_and_zero_native_group_amounts() { + let usage: ThreadUsage = serde_json::from_value(json!({ + "thread_id": "thread-123", + "estimated_usage_credits_micros": 0, + "native_usage_usd_micros": null, + "groups": [ + { "model": "legacy", "estimated_usage_credits_micros": 0 }, + { + "model": "unknown", + "estimated_usage_credits_micros": 0, + "native_usage_usd_micros": null + }, + { + "model": "zero", + "estimated_usage_credits_micros": 0, + "native_usage_usd_micros": 0 + } + ] + })) + .expect("decode native groups from legacy and current backend responses"); + + assert_eq!( + usage, + ThreadUsage { + thread_id: "thread-123".to_string(), + estimated_usage_credits_micros: 0, + estimated_usage_usd_micros: None, + native_usage_usd_micros: None, + groups: [("legacy", None), ("unknown", None), ("zero", Some(0))] + .into_iter() + .map( + |(model, native_usage_usd_micros)| ThreadUsageBreakdownGroup { + model: Some(model.to_string()), + reasoning_effort: None, + speed: None, + estimated_usage_credits_micros: 0, + native_usage_usd_micros, + net_new_input_tokens: None, + cached_input_tokens: None, + input_tokens: None, + output_tokens: None, + total_tokens: None, + } + ) + .collect(), + } + ); +} + +#[tokio::test] +async fn get_threads_usage_preserves_native_amounts_and_absence() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/codex/usage/thread_usage/query")) + .and(body_json(json!({ "thread_ids": ["priced", "unknown"] }))) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "threads": [ + { + "thread_id": "priced", + "estimated_usage_credits_micros": 46_000_000, + "estimated_usage_usd_micros": 2_505_000, + "native_usage_usd_micros": 3_250_000, + "groups": [ + { + "model": "test-model-a", + "reasoning_effort": "medium", + "speed": "standard", + "estimated_usage_credits_micros": 12_000_000, + "native_usage_usd_micros": 750_000 + }, + { + "model": "test-model-b", + "reasoning_effort": "high", + "speed": "fast", + "estimated_usage_credits_micros": 34_000_000, + "native_usage_usd_micros": 2_500_000 + } + ] + }, + { + "thread_id": "unknown", + "estimated_usage_credits_micros": 7_000_000, + "groups": [{ + "model": "legacy-model", + "estimated_usage_credits_micros": 7_000_000 + }] + } + ] + }))) + .expect(/*r*/ 1) + .mount(&server) + .await; + + let client = Client::new( + server.uri(), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); + assert_eq!( + client + .get_threads_usage(&["priced", "unknown"]) + .await + .expect("read native and legacy usage in one batch"), + vec![ + ThreadUsage { + thread_id: "priced".to_string(), + estimated_usage_credits_micros: 46_000_000, + estimated_usage_usd_micros: Some(2_505_000), + native_usage_usd_micros: Some(3_250_000), + groups: vec![ + ThreadUsageBreakdownGroup { + model: Some("test-model-a".to_string()), + reasoning_effort: Some("medium".to_string()), + speed: Some("standard".to_string()), + estimated_usage_credits_micros: 12_000_000, + native_usage_usd_micros: Some(750_000), + net_new_input_tokens: None, + cached_input_tokens: None, + input_tokens: None, + output_tokens: None, + total_tokens: None, + }, + ThreadUsageBreakdownGroup { + model: Some("test-model-b".to_string()), + reasoning_effort: Some("high".to_string()), + speed: Some("fast".to_string()), + estimated_usage_credits_micros: 34_000_000, + native_usage_usd_micros: Some(2_500_000), + net_new_input_tokens: None, + cached_input_tokens: None, + input_tokens: None, + output_tokens: None, + total_tokens: None, + }, + ], + }, + ThreadUsage { + thread_id: "unknown".to_string(), + estimated_usage_credits_micros: 7_000_000, + estimated_usage_usd_micros: None, + native_usage_usd_micros: None, + groups: vec![ThreadUsageBreakdownGroup { + model: Some("legacy-model".to_string()), + reasoning_effort: None, + speed: None, + estimated_usage_credits_micros: 7_000_000, + native_usage_usd_micros: None, + net_new_input_tokens: None, + cached_input_tokens: None, + input_tokens: None, + output_tokens: None, + total_tokens: None, + }], + }, + ] + ); +} diff --git a/codex-rs/tui/src/analytics/chats_tests.rs b/codex-rs/tui/src/analytics/chats_tests.rs index 1f932973df..09a2de636c 100644 --- a/codex-rs/tui/src/analytics/chats_tests.rs +++ b/codex-rs/tui/src/analytics/chats_tests.rs @@ -36,6 +36,7 @@ async fn incomplete_chat_batches_retain_available_estimates_without_inventing_ze thread_id: "available".into(), estimated_usage_credits_micros: 1250000, estimated_usage_usd_micros: None, + native_usage_usd_micros: None, groups: Vec::new(), }] ); diff --git a/codex-rs/tui/src/analytics/test_fixtures.rs b/codex-rs/tui/src/analytics/test_fixtures.rs index fb96e8482d..b9cf8d6b74 100644 --- a/codex-rs/tui/src/analytics/test_fixtures.rs +++ b/codex-rs/tui/src/analytics/test_fixtures.rs @@ -158,11 +158,13 @@ pub(super) fn chats() -> super::chats::Chats { thread_id: format!("test-{index}"), estimated_usage_credits_micros: credits * 1_000_000, estimated_usage_usd_micros: None, + native_usage_usd_micros: None, groups: vec![ThreadUsageBreakdownGroup { model: Some("GPT-5.6-Sol".into()), reasoning_effort: Some("high".into()), speed: Some("standard".into()), estimated_usage_credits_micros: credits * 1_000_000, + native_usage_usd_micros: None, net_new_input_tokens: None, cached_input_tokens: None, input_tokens: None,