diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c5a8bda798..f33f1c1ff4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4720,6 +4720,7 @@ dependencies = [ "codex-app-server-client", "codex-app-server-protocol", "codex-arg0", + "codex-backend-client", "codex-build-info", "codex-cloud-config", "codex-config", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index a61f170beb..0fe40e2707 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -32,6 +32,7 @@ codex-ansi-escape = { workspace = true } codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } +codex-backend-client = { workspace = true } codex-install-context = { workspace = true } codex-cloud-config = { workspace = true } codex-config = { workspace = true } diff --git a/codex-rs/tui/src/analytics.rs b/codex-rs/tui/src/analytics.rs new file mode 100644 index 0000000000..2304f6649f --- /dev/null +++ b/codex-rs/tui/src/analytics.rs @@ -0,0 +1,5 @@ +//! Account analytics data preparation, staged for the dashboard integration. + +mod models; +mod normalize; +mod report_data; diff --git a/codex-rs/tui/src/analytics/models.rs b/codex-rs/tui/src/analytics/models.rs new file mode 100644 index 0000000000..cd417b3ee5 --- /dev/null +++ b/codex-rs/tui/src/analytics/models.rs @@ -0,0 +1,52 @@ +//! Private analytics display types, independent of the app-server wire protocol. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AccountAnalyticsReport { + Usage, + Credits, + Messages, + Plugins, + Skills, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AccountAnalyticsGrouping { + Feature, + Model, + Surface, + TaskStart, + Speed, + Reasoning, + TokenType, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AccountAnalyticsUnit { + RelativeUsage, + Credits, + Count, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AccountAnalyticsHistory { + pub(crate) unit: AccountAnalyticsUnit, + /// Source freshness in Unix seconds, when provided. Not a completeness watermark. + pub(crate) updated_at: Option, + pub(crate) data: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AccountAnalyticsDay { + /// Source reporting date; display formatting belongs to the UI. + pub(crate) date: chrono::NaiveDate, + /// Full daily denominator, including non-task usage when grouped by task start. + pub(crate) total: f64, + pub(crate) values: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AccountAnalyticsValue { + pub(crate) key: String, + pub(crate) label: String, + pub(crate) value: f64, +} diff --git a/codex-rs/tui/src/analytics/normalize.rs b/codex-rs/tui/src/analytics/normalize.rs new file mode 100644 index 0000000000..8d60d9b96d --- /dev/null +++ b/codex-rs/tui/src/analytics/normalize.rs @@ -0,0 +1,340 @@ +//! Converts backend reports into display data while preserving missing values and full denominators. + +use super::models::AccountAnalyticsGrouping as Grouping; +use super::models::AccountAnalyticsReport as Report; +use super::models::*; +use super::report_data::AnalyticsData; +use chrono::DateTime; +use chrono::NaiveDate; +use std::collections::BTreeMap; + +pub(super) fn history( + response: AnalyticsData, + report: Report, + grouping: Grouping, + start: NaiveDate, + end: NaiveDate, +) -> Result, String> { + if report == Report::Messages { + match grouping { + Grouping::Model | Grouping::Surface => {} + Grouping::Feature + | Grouping::TaskStart + | Grouping::Speed + | Grouping::Reasoning + | Grouping::TokenType => { + return Err(String::from("Unsupported message count grouping.")); + } + } + } + let series = response.series.as_ref(); + let mut days: BTreeMap)> = BTreeMap::new(); + for record in response.data { + let date = + NaiveDate::parse_from_str(record.date.get(..10).unwrap_or(&record.date), "%Y-%m-%d") + .map_err(|_| String::from("Analytics returned an invalid date."))?; + if date < start || date > end { + continue; + } + let (total, values) = days.entry(date).or_default(); + match report { + Report::Usage => { + let daily = match grouping { + Grouping::Surface => record.product_surface_usage_values, + Grouping::Model => record + .models + .map(|models| -> Result<_, String> { + let mut values = BTreeMap::new(); + for model in models { + let amount = + model.credits.ok_or("Plan usage amount was not reported.")?; + *values.entry(model.model).or_insert(/*default*/ 0.0) += amount; + } + Ok(values) + }) + .transpose()?, + _ => None, + }; + if let Some(daily) = daily { + for (key, amount) in daily { + if !amount.is_finite() || amount < 0.0 { + return Err(String::from("Analytics returned an invalid amount.")); + } + let key = if grouping == Grouping::Surface { + if key.starts_with("work_") { + "work" + } else { + "codex" + } + .to_string() + } else if key.eq_ignore_ascii_case("other") { + "other".to_string() + } else { + key + }; + *total += amount; + *values.entry(key).or_default() += amount; + } + } else if let Some(attribution) = record.attribution { + for entry in attribution { + if !entry.value.is_finite() || entry.value < 0.0 { + return Err(String::from("Analytics returned an invalid amount.")); + } + *total += entry.value; + if grouping == Grouping::TaskStart + && entry.thread_source.as_deref() != Some("user") + { + continue; + } + let key = match grouping { + Grouping::Feature => entry.thread_source, + Grouping::Model => entry.model, + Grouping::Surface => entry.surface, + Grouping::TaskStart => { + entry.turn_trigger.map(|trigger| format!("start-{trigger}")) + } + Grouping::Speed | Grouping::Reasoning | Grouping::TokenType => { + return Err(String::from("unsupported analytics grouping")); + } + } + .unwrap_or_else(|| "unknown".to_string()); + *values.entry(key).or_default() += entry.value; + } + } else { + return Ok(None); + } + } + + Report::Credits => { + if series.is_some() { + let Some(credits) = record.values else { + return Ok(None); + }; + for (key, amount) in credits { + *values.entry(key).or_default() += amount; + *total += amount; + } + } else if matches!(grouping, Grouping::Model | Grouping::Speed) { + let Some(models) = record.models else { + return Ok(None); + }; + for model in models { + let amount = model.on_demand_credits.unwrap_or(/*default*/ 0.0); + let key = if grouping == Grouping::Speed { + model.speed.unwrap_or_else(|| "unknown".to_string()) + } else { + model.model + }; + *values.entry(key).or_default() += amount; + *total += amount; + } + } else if let Some(premium) = record.premium_usage_values { + for (surface, amount) in premium.credit_usage_credits { + if !amount.is_finite() { + return Err(String::from("Analytics returned an invalid amount.")); + } + *values.entry(surface).or_default() += amount; + *total += amount; + } + } else if let (Some(amount), Some(surface)) = + (record.credit_amount, record.product_surface) + { + *values.entry(surface).or_default() += amount; + *total += amount; + } else { + return Ok(None); + } + } + Report::Messages => { + let authoritative = record + .totals + .ok_or_else(|| String::from("Message count is unavailable."))? + .turns; + if !authoritative.is_finite() || authoritative < 0.0 { + return Err(String::from("Analytics returned an invalid amount.")); + } + let mut record_values = BTreeMap::::new(); + if grouping == Grouping::Model { + for model in record.models.unwrap_or_default() { + let key = if model.model.eq_ignore_ascii_case("codex-auto-review") + || model.model.eq_ignore_ascii_case("other") + { + "other".to_string() + } else { + model.model + }; + let count = model + .turns + .ok_or_else(|| String::from("Turn count is unavailable."))?; + if !count.is_finite() || count < 0.0 { + return Err(String::from("Analytics returned an invalid amount.")); + } + *record_values.entry(key).or_default() += count; + } + } else { + for client in record.clients.unwrap_or_default() { + if !client.turns.is_finite() || client.turns < 0.0 { + return Err(String::from("Analytics returned an invalid amount.")); + } + let key = match client.client_id.as_str() { + "CODEX_CLI" => "cli", + "CODEX_IDE_VSCODE" => "vscode", + "CODEX_WEB" => "web", + "CODEX_DESKTOP_APP" | "CODEX_WORK_DESKTOP" => "desktop_app", + "CODEX_WORK_WEB" => "work_web", + "CODEX_WORK_MOBILE" => "mobile", + "CODEX_GITHUB" => "github_code_review", + _ => "other", + }; + *record_values.entry(key.to_string()).or_default() += client.turns; + } + } + let remainder = + (authoritative - record_values.values().sum::()).max(/*other*/ 0.0); + if remainder > 0.0 { + *record_values.entry("other".to_string()).or_default() += remainder; + } + for (key, count) in record_values { + *values.entry(key).or_default() += count; + } + *total += authoritative; + } + Report::Plugins | Report::Skills => { + let overviews = if report == Report::Skills { + record.skill_usage_overviews + } else { + record.plugin_usage_overviews + } + .ok_or_else(|| String::from("Tool activity is unavailable."))?; + for tool in overviews { + if !tool.invocation_counts.is_finite() || tool.invocation_counts < 0.0 { + return Err(String::from("Analytics returned an invalid amount.")); + } + *values.entry(tool.display_name).or_default() += tool.invocation_counts; + *total += tool.invocation_counts; + } + } + } + if !total.is_finite() + || values + .values() + .any(|value| !value.is_finite() || (report != Report::Credits && *value < 0.0)) + { + return Err(String::from("Analytics returned an invalid amount.")); + } + } + if report == Report::Usage + && grouping == Grouping::Model + && response.units.as_deref() != Some("credits") + { + let mut totals = BTreeMap::::new(); + for (_, values) in days.values() { + for (key, value) in values { + *totals.entry(key.clone()).or_default() += value; + } + } + let total: f64 = totals.values().sum(); + for (_, values) in days.values_mut() { + let mut other = 0.0; + values.retain(|key, value| { + let amount = totals[key]; + if key.eq_ignore_ascii_case("other") || (amount > 0.0 && amount < total * 0.01) { + other += *value; + false + } else { + true + } + }); + if other > 0.0 { + values.insert("other".to_string(), other); + } + } + } + if report == Report::Credits { + for date in start.iter_days().take_while(|date| *date <= end) { + days.entry(date).or_default(); + } + } + Ok(Some(AccountAnalyticsHistory { + unit: match report { + Report::Usage if response.units.as_deref() == Some("credits") => { + AccountAnalyticsUnit::Credits + } + Report::Usage => AccountAnalyticsUnit::RelativeUsage, + Report::Credits => AccountAnalyticsUnit::Credits, + Report::Messages | Report::Plugins | Report::Skills => AccountAnalyticsUnit::Count, + }, + updated_at: response + .data_freshness_ts + .as_deref() + .and_then(|timestamp| DateTime::parse_from_rfc3339(timestamp).ok()) + .map(|timestamp| timestamp.timestamp()), + data: days + .into_iter() + .map(|(date, (total, values))| AccountAnalyticsDay { + date, + total, + values: values + .into_iter() + .map(|(key, value)| AccountAnalyticsValue { + label: series + .and_then(|series| series.iter().find(|series| series.key == key)) + .map(|series| series.label.clone()) + .unwrap_or_else(|| label(&key).to_string()), + key, + value, + }) + .collect(), + }) + .collect(), + })) +} + +pub(super) fn label(key: &str) -> &str { + match key { + "user" => "Tasks", + "subagent" => "Subagents", + "image_generation" => "Image generation", + "automation" => "Automations", + "guardian_review" => "Guardian review", + "automated_review" => "Auto review", + "agent_identity" => "Workspace agents", + "memory_consolidation" => "Memory consolidation", + "cli" => "CLI", + "desktop_app" => "Desktop app", + "vscode" => "VS Code", + "web" => "Web", + "work_web" => "Work web", + "work_desktop" => "Work desktop", + "work_mobile" => "Work mobile", + "mobile" => "Mobile", + "slack" => "Slack", + "linear" => "Linear", + "jetbrains" => "JetBrains", + "sdk" => "SDK", + "exec" => "Exec", + "github" => "GitHub", + "codex" => "Codex", + "work" => "Work", + "code_review" | "github_code_review" => "Code review", + "start-user" | "start-composer" => "User messages", + "start-goal" => "Goals", + "start-composer_queue" => "Queued messages", + "start-composer_queue_run_now" => "Queued messages run now", + "start-automation_cron_scheduled" => "Scheduled automations", + "start-automation_cron_run_now" => "Automations run now", + "start-automation_heartbeat_scheduled" => "Scheduled follow-ups", + "start-automation_heartbeat_run_now" => "Follow-ups run now", + "start-app_tool_create_thread" => "Agent-created tasks", + "start-app_tool_send_message" => "Agent follow-ups", + "unknown" | "start-unknown" => "Unknown", + "fast" => "Fast", + "standard" => "Standard", + "other" | "start-other" => "Other", + _ => key, + } +} + +#[cfg(test)] +#[path = "normalize_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/normalize_tests.rs b/codex-rs/tui/src/analytics/normalize_tests.rs new file mode 100644 index 0000000000..7191298436 --- /dev/null +++ b/codex-rs/tui/src/analytics/normalize_tests.rs @@ -0,0 +1,306 @@ +//! Regression coverage for daily report normalization and billing semantics. + +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn task_start_keeps_the_full_denominator_and_missing_attribution_is_unavailable() { + let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap(); + let response = serde_json::from_value(json!({"data": [{"date": "2026-01-09", "attribution": [ + {"thread_source": "user", "turn_trigger": "composer", "value": 30}, + {"thread_source": "subagent", "value": 70} + ]}]})) + .unwrap(); + assert_eq!( + history(response, Report::Usage, Grouping::TaskStart, date, date).unwrap(), + Some(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::RelativeUsage, + updated_at: None, + data: vec![AccountAnalyticsDay { + date: "2026-01-09".parse().unwrap(), + total: 100.0, + values: vec![AccountAnalyticsValue { + key: "start-composer".to_string(), + label: "User messages".to_string(), + value: 30.0 + }], + }], + }) + ); + let response = serde_json::from_value(json!({"data": [{"date": "2026-01-09"}]})).unwrap(); + assert_eq!( + history(response, Report::Usage, Grouping::Feature, date, date).unwrap(), + None + ); +} + +#[test] +fn signed_credits_keep_daily_activity_when_the_period_nets_to_zero() { + let start = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 8).unwrap(); + let end = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap(); + let response = serde_json::from_value(json!({"data": [ + {"date": "2026-01-08", "product_surface": "cli", "credit_amount": 0.004}, + {"date": "2026-01-09", "product_surface": "desktop_app", "credit_amount": -0.004} + ]})) + .unwrap(); + assert_eq!( + history(response, Report::Credits, Grouping::Surface, start, end).unwrap(), + Some(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Credits, + updated_at: None, + data: [ + ("2026-01-08", 0.004, "cli", "CLI"), + ("2026-01-09", -0.004, "desktop_app", "Desktop app") + ] + .into_iter() + .map(|(date, amount, key, label)| AccountAnalyticsDay { + date: date.parse().unwrap(), + total: amount, + values: vec![AccountAnalyticsValue { + key: key.to_string(), + label: label.to_string(), + value: amount + }], + }) + .collect(), + }) + ); +} + +#[test] +fn message_surfaces_include_both_desktop_clients_and_unmapped_counts() { + let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap(); + let response = serde_json::from_value( + json!({"data": [{"date": "2026-01-09", "totals": {"turns": 10}, "clients": [ + {"client_id": "CODEX_DESKTOP_APP", "turns": 2}, + {"client_id": "CODEX_WORK_DESKTOP", "turns": 3}, + {"client_id": "new_client", "turns": 1} + ]}]}), + ) + .unwrap(); + assert_eq!( + history(response, Report::Messages, Grouping::Surface, date, date) + .unwrap() + .unwrap() + .data, + vec![AccountAnalyticsDay { + date: "2026-01-09".parse().unwrap(), + total: 10.0, + values: vec![ + AccountAnalyticsValue { + key: "desktop_app".to_string(), + label: "Desktop app".to_string(), + value: 5.0 + }, + AccountAnalyticsValue { + key: "other".to_string(), + label: "Other".to_string(), + value: 5.0 + }, + ], + }] + ); +} + +#[test] +fn enterprise_credit_series_keep_labels_signed_values_and_freshness() { + let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap(); + let response = serde_json::from_value(json!({ + "series": [{"key": "medium", "label": "Medium", "total": 12.5}, {"key": "high", "label": "High", "total": -0.004}], + "data_freshness_ts": "2026-01-09T10:00:00Z", + "data": [{"date": "2026-01-09", "values": {"medium": 12.5, "high": -0.004}}] + })).unwrap(); + assert_eq!( + history(response, Report::Credits, Grouping::Reasoning, date, date).unwrap(), + Some(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Credits, + updated_at: Some(1767952800), + data: vec![AccountAnalyticsDay { + date, + total: 12.496, + values: vec![ + AccountAnalyticsValue { + key: "high".into(), + label: "High".into(), + value: -0.004 + }, + AccountAnalyticsValue { + key: "medium".into(), + label: "Medium".into(), + value: 12.5 + } + ], + }], + }) + ); +} + +#[test] +fn credit_timestamps_and_legacy_model_usage_follow_app_fallbacks() { + let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap(); + let credits = serde_json::from_value(json!({"data": [{"date": "2026-01-09T05:00:00Z", "product_surface": "cli", "credit_amount": 12.5}]})).unwrap(); + let usage = serde_json::from_value(json!({"data": [{"date": "2026-01-09", "models": [{"model": "model-a", "credits": 12.5}]}]})).unwrap(); + let credits = history(credits, Report::Credits, Grouping::Surface, date, date) + .unwrap() + .unwrap(); + let usage = history(usage, Report::Usage, Grouping::Model, date, date) + .unwrap() + .unwrap(); + assert_eq!( + ( + credits.data[0].total, + usage.data[0].total, + usage.data[0].values[0].key.as_str() + ), + (12.5, 12.5, "model-a") + ); +} + +#[test] +fn count_reports_preserve_explicit_zero_and_omit_unreported_days() { + let start = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 8).unwrap(); + let end = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 10).unwrap(); + for (report, field) in [ + (Report::Messages, "totals"), + (Report::Plugins, "plugin_usage_overviews"), + (Report::Skills, "skill_usage_overviews"), + ] { + let value = if report == Report::Messages { + json!({"turns": 0}) + } else { + json!([]) + }; + let response = + serde_json::from_value(json!({"data": [{"date": "2026-01-08", (field): value}]})) + .unwrap(); + assert_eq!( + history(response, report, Grouping::Surface, start, end).unwrap(), + Some(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Count, + updated_at: None, + data: vec![AccountAnalyticsDay { + date: start, + total: 0.0, + values: Vec::new() + }], + }) + ); + } +} + +#[test] +fn consumer_plan_prefers_daily_products_and_models_over_attribution() { + let date = "2026-09-01".parse().unwrap(); + let response: AnalyticsData = serde_json::from_value(json!({"units":"relative", "data":[{ + "date":"2026-09-01", + "product_surface_usage_values":{"work_desktop":12,"work_web":8,"cli":30,"desktop_app":50}, + "models":[{"model":"major","credits":98.5},{"model":"one-percent","credits":1},{"model":"tiny","credits":0.3},{"model":"OTHER","credits":0.2}], + "attribution":[{"surface":"wrong","model":"wrong","value":999}] + }]})).unwrap(); + for (grouping, expected) in [ + ( + Grouping::Surface, + vec![("codex", "Codex", 80.0), ("work", "Work", 20.0)], + ), + ( + Grouping::Model, + vec![ + ("major", "major", 98.5), + ("one-percent", "one-percent", 1.0), + ("other", "Other", 0.5), + ], + ), + ] { + assert_eq!( + history(response.clone(), Report::Usage, grouping, date, date).unwrap(), + Some(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::RelativeUsage, + updated_at: None, + data: vec![AccountAnalyticsDay { + date, + total: 100.0, + values: expected + .into_iter() + .map(|(key, label, value)| AccountAnalyticsValue { + key: key.into(), + label: label.into(), + value + }) + .collect() + }], + }) + ); + } + let mut credits = response; + credits.units = Some("credits".into()); + let model = history(credits, Report::Usage, Grouping::Model, date, date) + .unwrap() + .unwrap(); + assert_eq!( + (model.unit, model.data[0].values.len()), + (AccountAnalyticsUnit::Credits, 4) + ); +} + +#[test] +fn duplicate_message_dates_preserve_each_records_other_remainder() { + let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap(); + for grouping in [Grouping::Model, Grouping::Surface] { + let response = serde_json::from_value(json!({"data": [ + {"date": "2026-01-09", "totals": {"turns": 10}, "models": [{"model": "example", "turns": 8}], "clients": [{"client_id": "CODEX_CLI", "turns": 8}]}, + {"date": "2026-01-09", "totals": {"turns": 10}, "models": [{"model": "example", "turns": 8}], "clients": [{"client_id": "CODEX_CLI", "turns": 8}]} + ]})).unwrap(); + let (key, label) = if grouping == Grouping::Model { + ("example", "example") + } else { + ("cli", "CLI") + }; + assert_eq!( + history(response, Report::Messages, grouping, date, date).unwrap(), + Some(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Count, + updated_at: None, + data: vec![AccountAnalyticsDay { + date, + total: 20.0, + values: vec![ + AccountAnalyticsValue { + key: key.into(), + label: label.into(), + value: 16.0 + }, + AccountAnalyticsValue { + key: "other".into(), + label: "Other".into(), + value: 4.0 + }, + ] + }], + }) + ); + } +} + +#[test] +fn unsupported_message_groupings_fail_even_without_records() { + let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap(); + for grouping in [ + Grouping::Feature, + Grouping::TaskStart, + Grouping::Speed, + Grouping::Reasoning, + Grouping::TokenType, + ] { + for data in [ + json!([]), + json!([{"date": "2026-01-09", "totals": {"turns": 10}, "clients": []}]), + ] { + let response = serde_json::from_value(json!({"data": data})).unwrap(); + assert_eq!( + history(response, Report::Messages, grouping, date, date), + Err("Unsupported message count grouping.".into()) + ); + } + } +} diff --git a/codex-rs/tui/src/analytics/report_data.rs b/codex-rs/tui/src/analytics/report_data.rs new file mode 100644 index 0000000000..0e22d11244 --- /dev/null +++ b/codex-rs/tui/src/analytics/report_data.rs @@ -0,0 +1,289 @@ +//! Projects endpoint-specific backend models into inputs for display normalization. +//! This representation is never used to deserialize HTTP responses. +//! Optional fields select the inputs used by each report; missing backend values stay missing. + +use codex_backend_client::AnalyticsResponse; +use codex_backend_client::analytics_models as backend; + +/// Report values projected for normalization after endpoint-specific decoding. +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsData { + pub(super) data: Vec, + pub(super) units: Option, + pub(super) data_freshness_ts: Option, + pub(super) series: Option>, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsSeries { + pub(super) key: String, + pub(super) label: String, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsRecord { + pub(super) date: String, + pub(super) values: Option>, + pub(super) product_surface_usage_values: Option>, + pub(super) premium_usage_values: Option, + pub(super) attribution: Option>, + pub(super) credit_amount: Option, + pub(super) product_surface: Option, + pub(super) totals: Option, + pub(super) models: Option>, + pub(super) groups: Option>, + pub(super) clients: Option>, + pub(super) plugin_usage_overviews: Option>, + pub(super) skill_usage_overviews: Option>, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct PremiumUsage { + pub(super) credit_usage_credits: std::collections::BTreeMap, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct UsageAttribution { + pub(super) thread_source: Option, + pub(super) turn_trigger: Option, + pub(super) model: Option, + pub(super) surface: Option, + pub(super) value: f64, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsCount { + pub(super) turns: f64, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsModel { + pub(super) model: String, + pub(super) credits: Option, + pub(super) uncached_text_input_tokens: Option, + pub(super) cached_text_input_tokens: Option, + pub(super) text_output_tokens: Option, + pub(super) text_total_tokens: Option, + pub(super) turns: Option, + pub(super) on_demand_credits: Option, + pub(super) speed: Option, +} + +/// Enterprise model groups carry text token counts independently of billing amounts. +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsTokenGroup { + pub(super) dimensions: std::collections::BTreeMap, + #[cfg_attr(test, serde(default))] + pub(super) is_other: bool, + pub(super) uncached_text_input_tokens: Option, + pub(super) cached_text_input_tokens: Option, + pub(super) text_output_tokens: Option, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsClient { + pub(super) client_id: String, + pub(super) turns: f64, +} + +#[derive(Clone, Debug, Default)] +#[cfg_attr(test, derive(serde::Deserialize))] +pub(super) struct AnalyticsTool { + pub(super) display_name: String, + pub(super) invocation_counts: f64, +} + +impl From for AnalyticsData { + fn from(response: AnalyticsResponse) -> Self { + match response { + AnalyticsResponse::Usage(response) => Self { + units: response.units, + data_freshness_ts: response.data_freshness_ts, + data: response + .data + .into_iter() + .map(|record| AnalyticsRecord { + date: record.date, + product_surface_usage_values: Some(record.product_surface_usage_values), + premium_usage_values: record.premium_usage_values.map(|value| { + PremiumUsage { + credit_usage_credits: value.credit_usage_credits, + } + }), + attribution: record.attribution.map(|values| { + values + .into_iter() + .map(|value| UsageAttribution { + thread_source: Some(value.thread_source), + turn_trigger: Some(value.turn_trigger), + model: Some(value.model), + surface: Some(value.surface), + value: value.value, + }) + .collect() + }), + models: record + .models + .map(|values| values.into_iter().map(AnalyticsModel::from).collect()), + groups: record.groups.map(|values| { + values + .into_iter() + .map(|value| AnalyticsTokenGroup { + dimensions: value.dimensions, + is_other: value.is_other.unwrap_or(/*default*/ false), + uncached_text_input_tokens: value + .uncached_text_input_tokens + .map(|count| count as f64), + cached_text_input_tokens: value + .cached_text_input_tokens + .map(|count| count as f64), + text_output_tokens: value + .text_output_tokens + .map(|count| count as f64), + }) + .collect() + }), + ..Default::default() + }) + .collect(), + ..Default::default() + }, + AnalyticsResponse::Credits(response) => Self { + data: response + .data + .into_iter() + .map(|record| AnalyticsRecord { + date: record.date, + credit_amount: Some(record.credit_amount), + product_surface: Some(record.product_surface), + ..Default::default() + }) + .collect(), + ..Default::default() + }, + AnalyticsResponse::EnterpriseCredits(response) => Self { + data_freshness_ts: response.data_freshness_ts, + series: Some( + response + .series + .into_iter() + .map(|value| AnalyticsSeries { + key: value.key, + label: value.label, + }) + .collect(), + ), + data: response + .data + .into_iter() + .map(|record| AnalyticsRecord { + date: record.date, + values: Some(record.values), + ..Default::default() + }) + .collect(), + ..Default::default() + }, + AnalyticsResponse::Messages(response) => Self { + data: response + .data + .into_iter() + .map(|record| AnalyticsRecord { + date: record.date, + totals: Some(AnalyticsCount { + turns: record.totals.turns as f64, + }), + clients: Some( + record + .clients + .into_iter() + .map(|value| AnalyticsClient { + client_id: value.client_id, + turns: value.turns as f64, + }) + .collect(), + ), + models: record + .models + .map(|values| values.into_iter().map(AnalyticsModel::from).collect()), + ..Default::default() + }) + .collect(), + ..Default::default() + }, + AnalyticsResponse::Plugins(response) => Self { + data_freshness_ts: response.data_freshness_ts, + data: response + .data + .into_iter() + .map(|record| AnalyticsRecord { + date: record.date, + plugin_usage_overviews: Some( + record + .plugin_usage_overviews + .into_iter() + .map(|value| AnalyticsTool { + display_name: value.display_name, + invocation_counts: value.invocation_counts as f64, + }) + .collect(), + ), + ..Default::default() + }) + .collect(), + ..Default::default() + }, + AnalyticsResponse::Skills(response) => Self { + data_freshness_ts: response.data_freshness_ts, + data: response + .data + .into_iter() + .map(|record| AnalyticsRecord { + date: record.date, + skill_usage_overviews: Some( + record + .skill_usage_overviews + .into_iter() + .map(|value| AnalyticsTool { + display_name: value.display_name, + invocation_counts: value.invocation_counts as f64, + }) + .collect(), + ), + ..Default::default() + }) + .collect(), + ..Default::default() + }, + } + } +} + +impl From for AnalyticsModel { + fn from(value: backend::ModelUsage) -> Self { + Self { + model: value.model, + credits: Some(value.credits), + uncached_text_input_tokens: value.uncached_text_input_tokens.map(|count| count as f64), + cached_text_input_tokens: value.cached_text_input_tokens.map(|count| count as f64), + text_output_tokens: value.text_output_tokens.map(|count| count as f64), + text_total_tokens: value.text_total_tokens.map(|count| count as f64), + turns: value.turns.map(|count| count as f64), + on_demand_credits: value.on_demand_credits, + speed: value.speed, + } + } +} + +#[cfg(test)] +#[path = "report_data_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/analytics/report_data_tests.rs b/codex-rs/tui/src/analytics/report_data_tests.rs new file mode 100644 index 0000000000..237a8e0055 --- /dev/null +++ b/codex-rs/tui/src/analytics/report_data_tests.rs @@ -0,0 +1,84 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn typed_usage_preserves_missing_counts_and_supplied_zero() { + let input = backend::DailyProductSurfaceUsageResponse { + data: vec![backend::DailyProductSurfaceUsage { + date: "2026-01-09".into(), + models: Some(vec![backend::ModelUsage { + model: "example".into(), + credits: 1.5, + cached_text_input_tokens: Some(0), + text_output_tokens: Some(42), + ..Default::default() + }]), + ..Default::default() + }], + ..Default::default() + }; + let actual = AnalyticsData::from(AnalyticsResponse::Usage(input)); + let model = &actual.data[0].models.as_ref().unwrap()[0]; + assert_eq!( + ( + model.credits, + model.uncached_text_input_tokens, + model.cached_text_input_tokens, + model.text_output_tokens + ), + (Some(1.5), None, Some(0.0), Some(42.0)) + ); +} + +#[test] +fn typed_message_records_keep_independent_daily_remainders() { + use super::super::models::*; + let date = "2026-01-09".parse().unwrap(); + let row = backend::DailyWorkspaceUsageCount { + date: "2026-01-09".into(), + totals: backend::WorkspaceUsageCount { + turns: 10, + ..Default::default() + }, + clients: vec![backend::ClientWorkspaceUsageCount { + client_id: "CODEX_CLI".into(), + turns: 8, + ..Default::default() + }], + ..Default::default() + }; + let response = AnalyticsResponse::Messages(backend::DailyWorkspaceUsageCountResponse { + data: vec![row.clone(), row], + ..Default::default() + }); + assert_eq!( + super::super::normalize::history( + response.into(), + AccountAnalyticsReport::Messages, + AccountAnalyticsGrouping::Surface, + date, + date + ) + .unwrap(), + Some(AccountAnalyticsHistory { + unit: AccountAnalyticsUnit::Count, + updated_at: None, + data: vec![AccountAnalyticsDay { + date, + total: 20.0, + values: vec![ + AccountAnalyticsValue { + key: "cli".into(), + label: "CLI".into(), + value: 16.0 + }, + AccountAnalyticsValue { + key: "other".into(), + label: "Other".into(), + value: 4.0 + }, + ] + }], + }) + ); +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1b3198ae8d..f8e4158698 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -102,6 +102,12 @@ pub(crate) use codex_app_server_client::legacy_core; pub(crate) use worktree_startup::ManagedTuiWorktree; mod additional_dirs; +// Removed when the final stack layer connects Analytics to /usage. +#[expect( + dead_code, + reason = "analytics is being introduced in independently tested stack layers" +)] +mod analytics; mod app; mod app_backtrack; mod app_command;