diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 811bd45d43..aeda1920cd 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -34,6 +34,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use std::fmt; +pub(crate) mod analytics; mod chatgpt_turn_cost; mod rate_limit_resets; mod thread_usage; diff --git a/codex-rs/backend-client/src/client/analytics.rs b/codex-rs/backend-client/src/client/analytics.rs new file mode 100644 index 0000000000..3ef85ac99b --- /dev/null +++ b/codex-rs/backend-client/src/client/analytics.rs @@ -0,0 +1,130 @@ +//! Authenticated account analytics reads. Attribution and missing amounts remain unmodified. + +use super::Client; +use super::PathStyle; +use super::RequestError; +use codex_backend_openapi_models::models::analytics as models; +use http::Method; + +/// The bounded set of reports used by consumer and workspace Analytics views. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum AnalyticsReport { + Usage, + EnterpriseTokens, + Credits, + WorkspaceCredits, + EnterpriseCredits { breakdown: &'static str }, + Messages, + Plugins { limit: u8 }, + Skills { limit: u8 }, +} + +/// Endpoint-specific backend contracts; normalization belongs to the consumer. +#[derive(Clone, Debug, PartialEq)] +pub enum AnalyticsResponse { + Usage(models::DailyProductSurfaceUsageResponse), + Credits(models::CreditUsageEventsResponse), + EnterpriseCredits(models::CurrentUserCreditUsageResponse), + Messages(models::DailyWorkspaceUsageCountResponse), + Plugins(models::PluginUsageMetricsResponse), + Skills(models::DailySkillUsageMetricsResponse), +} + +impl Client { + /// Fetches one report for an inclusive UTC date range supplied by the caller. + pub async fn get_account_analytics( + &self, + report: AnalyticsReport, + start_date: &str, + end_date: &str, + ) -> Result { + let route = match report { + AnalyticsReport::Usage => "usage/daily-token-usage-breakdown", + AnalyticsReport::Credits => "usage/credit-usage-events", + AnalyticsReport::WorkspaceCredits | AnalyticsReport::EnterpriseTokens => { + "usage/daily-workspace-user-token-usage-breakdown" + } + AnalyticsReport::EnterpriseCredits { .. } => "usage/daily-workspace-user-credit-usage", + AnalyticsReport::Messages => "analytics/daily-workspace-usage-counts", + AnalyticsReport::Plugins { .. } => "analytics/daily-plugin-usage-metrics", + AnalyticsReport::Skills { .. } => "analytics/daily-skill-usage-metrics", + }; + let prefix = match self.path_style { + PathStyle::CodexApi => "api/codex", + PathStyle::ChatGptApi => "wham", + }; + let mut url = url::Url::parse(&format!("{}/{prefix}/{route}", self.base_url)) + .map_err(anyhow::Error::from)?; + if report != AnalyticsReport::Credits { + url.query_pairs_mut() + .append_pair("start_date", start_date) + .append_pair("end_date", end_date); + if let AnalyticsReport::EnterpriseCredits { breakdown } = report { + url.query_pairs_mut().append_pair("breakdown", breakdown); + } else { + url.query_pairs_mut().append_pair("group_by", "day"); + } + } + if matches!( + report, + AnalyticsReport::Messages + | AnalyticsReport::Plugins { .. } + | AnalyticsReport::Skills { .. } + ) { + url.query_pairs_mut().append_pair("workspace_user", "true"); + } + match report { + AnalyticsReport::EnterpriseTokens => { + url.query_pairs_mut() + .append_pair("breakdown_by", "model") + .append_pair("modes", "codex") + .append_pair("modes", "work"); + } + AnalyticsReport::Plugins { limit } => { + url.query_pairs_mut() + .append_pair("top_plugin_limit", &limit.to_string()); + } + AnalyticsReport::Skills { limit } => { + url.query_pairs_mut() + .append_pair("top_skill_limit", &limit.to_string()); + } + AnalyticsReport::Usage + | AnalyticsReport::Credits + | AnalyticsReport::WorkspaceCredits + | AnalyticsReport::EnterpriseCredits { .. } + | AnalyticsReport::Messages => {} + } + let request = self + .request(Method::GET, url.as_str()) + .headers(self.headers()); + let (body, _) = self + .exec_request_detailed(request, "GET", url.as_str()) + .await?; + // Do not include an untyped billing response in errors or diagnostics. + let response = match report { + AnalyticsReport::Usage + | AnalyticsReport::EnterpriseTokens + | AnalyticsReport::WorkspaceCredits => { + serde_json::from_str(&body).map(AnalyticsResponse::Usage) + } + AnalyticsReport::Credits => serde_json::from_str(&body).map(AnalyticsResponse::Credits), + AnalyticsReport::EnterpriseCredits { .. } => { + serde_json::from_str(&body).map(AnalyticsResponse::EnterpriseCredits) + } + AnalyticsReport::Messages => { + serde_json::from_str(&body).map(AnalyticsResponse::Messages) + } + AnalyticsReport::Plugins { .. } => { + serde_json::from_str(&body).map(AnalyticsResponse::Plugins) + } + AnalyticsReport::Skills { .. } => { + serde_json::from_str(&body).map(AnalyticsResponse::Skills) + } + }; + response.map_err(|_| RequestError::Other(anyhow::anyhow!("Invalid analytics response."))) + } +} + +#[cfg(test)] +#[path = "analytics_tests.rs"] +mod tests; diff --git a/codex-rs/backend-client/src/client/analytics_tests.rs b/codex-rs/backend-client/src/client/analytics_tests.rs new file mode 100644 index 0000000000..b8650f83fc --- /dev/null +++ b/codex-rs/backend-client/src/client/analytics_tests.rs @@ -0,0 +1,190 @@ +//! HTTP contract coverage for account analytics report reads. + +use super::*; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; +use pretty_assertions::assert_eq; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; + +#[tokio::test] +async fn account_analytics_uses_personal_daily_scope_and_plugin_contract() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/codex/analytics/daily-plugin-usage-metrics")) + .and(query_param("start_date", "2026-01-03")) + .and(query_param("end_date", "2026-01-09")) + .and(query_param("group_by", "day")) + .and(query_param("workspace_user", "true")) + .and(query_param("top_plugin_limit", "10")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "data": [{"date": "2026-01-03", "plugin_usage_overviews": [{ + "plugin_id": null, "plugin_name": "example", "marketplace": null, + "display_name": "Example plugin", "invocation_counts": 12 + }]}] + }))) + .expect(/*r*/ 1) + .mount(&server) + .await; + let client = Client::new( + server.uri(), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); + let response = client + .get_account_analytics( + AnalyticsReport::Plugins { limit: 10 }, + "2026-01-03", + "2026-01-09", + ) + .await + .unwrap(); + assert_eq!( + response, + AnalyticsResponse::Plugins(models::PluginUsageMetricsResponse { + data: vec![models::PluginUsageBucket { + date: "2026-01-03".into(), + plugin_usage_overviews: vec![models::PluginUsageOverview { + plugin_id: None, + plugin_name: "example".into(), + display_name: "Example plugin".into(), + marketplace: None, + invocation_counts: 12, + }], + }], + data_freshness_ts: None, + group_by: None, + }) + ); +} + +#[tokio::test] +async fn account_analytics_preserves_signed_credit_events() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/wham/usage/credit-usage-events")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "data": [{"date": "2026-01-03", "product_surface": "cli", "credit_amount": -0.004}] + }))) + .expect(/*r*/ 1) + .mount(&server) + .await; + let client = Client::new( + server.uri(), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ) + .with_path_style(PathStyle::ChatGptApi); + let response = client + .get_account_analytics(AnalyticsReport::Credits, "2026-01-03", "2026-01-09") + .await + .unwrap(); + assert_eq!( + response, + AnalyticsResponse::Credits(models::CreditUsageEventsResponse { + data: vec![models::CreditUsageEventBySurface { + date: "2026-01-03".into(), + product_surface: "cli".into(), + credit_amount: -0.004, + usage_id: None, + }], + }) + ); +} + +#[tokio::test] +async fn account_analytics_rejects_another_reports_shape_without_logging_the_body() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/codex/analytics/daily-workspace-usage-counts")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "data": [{"date": "2026-01-03", "product_surface": "sensitive-value", "credit_amount": 10}] + }))) + .expect(/*r*/ 1) + .mount(&server) + .await; + let client = Client::new( + server.uri(), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); + let error = client + .get_account_analytics(AnalyticsReport::Messages, "2026-01-03", "2026-01-09") + .await + .unwrap_err(); + assert_eq!(error.to_string(), "Invalid analytics response."); +} + +#[tokio::test] +async fn account_analytics_decodes_each_endpoint_and_accepts_new_string_values() { + for (report, route, body, expected) in [ + ( + AnalyticsReport::Usage, + "usage/daily-token-usage-breakdown", + json!({"data": [{"date": "2026-01-03", "product_surface_usage_values": {"future_surface": 1.0}}], "units": "future_unit"}), + AnalyticsResponse::Usage(models::DailyProductSurfaceUsageResponse { + data: vec![models::DailyProductSurfaceUsage { + date: "2026-01-03".into(), + product_surface_usage_values: [("future_surface".into(), 1.0)].into(), + ..Default::default() + }], + units: Some("future_unit".into()), + ..Default::default() + }), + ), + ( + AnalyticsReport::EnterpriseCredits { breakdown: "model" }, + "usage/daily-workspace-user-credit-usage", + json!({"data": [], "series": [], "breakdown": "model"}), + AnalyticsResponse::EnterpriseCredits(models::CurrentUserCreditUsageResponse { + breakdown: "model".into(), + ..Default::default() + }), + ), + ( + AnalyticsReport::Messages, + "analytics/daily-workspace-usage-counts", + json!({"data": []}), + AnalyticsResponse::Messages(models::DailyWorkspaceUsageCountResponse::default()), + ), + ( + AnalyticsReport::Skills { limit: 10 }, + "analytics/daily-skill-usage-metrics", + json!({"data": []}), + AnalyticsResponse::Skills(models::DailySkillUsageMetricsResponse::default()), + ), + ( + AnalyticsReport::WorkspaceCredits, + "usage/daily-workspace-user-token-usage-breakdown", + json!({"data": []}), + AnalyticsResponse::Usage(models::DailyProductSurfaceUsageResponse::default()), + ), + ( + AnalyticsReport::EnterpriseTokens, + "usage/daily-workspace-user-token-usage-breakdown", + json!({"data": []}), + AnalyticsResponse::Usage(models::DailyProductSurfaceUsageResponse::default()), + ), + ] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/api/codex/{route}"))) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(body)) + .expect(/*r*/ 1) + .mount(&server) + .await; + let client = Client::new( + server.uri(), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ); + assert_eq!( + client + .get_account_analytics(report, "2026-01-03", "2026-01-09") + .await + .unwrap(), + expected + ); + } +} diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 4329da770b..a168cad1e0 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -8,6 +8,8 @@ pub use client::Client; pub use client::RequestError; pub use client::ThreadUsage; pub use client::ThreadUsageBreakdownGroup; +pub use client::analytics::AnalyticsReport; +pub use client::analytics::AnalyticsResponse; pub use client::turn_usage::ApiKeyResponseCost; pub use client::turn_usage::ApiKeyTurnCost; pub use client::turn_usage::ApiKeyTurnCostStatus; @@ -36,3 +38,5 @@ pub use types::TokenUsageProfile; pub use types::TokenUsageProfileDailyBucket; pub use types::TokenUsageProfileStats; pub use types::TurnAttemptsSiblingTurnsResponse; + +pub use codex_backend_openapi_models::models::analytics as analytics_models; diff --git a/codex-rs/codex-backend-openapi-models/src/models/analytics.rs b/codex-rs/codex-backend-openapi-models/src/models/analytics.rs new file mode 100644 index 0000000000..3962a88514 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/analytics.rs @@ -0,0 +1,230 @@ +// Generated by scripts/generate_analytics_models.py; do not edit. +// Selected backend schema SHA-256: 8dad4dcab85a89fd8091236929727587118dcaad334e10148ae8cf95dd640cc8 +// String enums remain open for forward compatibility. + +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ActiveUsersSummary { + pub total_users: i64, + pub clients: Vec, + pub models: Option>, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ClientActiveUsersCount { + pub client_id: String, + pub users: i64, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ClientWorkspaceUsageCount { + pub client_id: String, + pub users: i64, + pub threads: i64, + pub turns: i64, + pub credits: f64, + pub cost_usd: Option, + pub on_demand_credits: Option, + pub uncached_text_input_tokens: Option, + pub cached_text_input_tokens: Option, + pub text_output_tokens: Option, + pub text_total_tokens: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ConsumptionUsageGroup { + pub dimensions: std::collections::BTreeMap, + pub is_other: Option, + pub credits: f64, + pub on_demand_credits: Option, + pub uncached_text_input_tokens: Option, + pub cached_text_input_tokens: Option, + pub text_output_tokens: Option, + pub text_total_tokens: Option, + pub total_tokens: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct CreditUsageEventBySurface { + pub date: String, + pub product_surface: String, + pub credit_amount: f64, + pub usage_id: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct CreditUsageEventsResponse { + pub data: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct CurrentUserCreditUsageDatum { + pub date: String, + pub values: std::collections::BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct CurrentUserCreditUsageResponse { + pub breakdown: String, + pub data: Vec, + pub series: Vec, + pub unit: Option, + pub data_freshness_ts: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct CurrentUserCreditUsageSeries { + pub key: String, + pub label: String, + pub total: f64, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct DailyProductSurfaceUsage { + pub attribution: Option>, + pub date: String, + pub product_surface_usage_values: std::collections::BTreeMap, + pub premium_usage_values: Option, + pub models: Option>, + pub groups: Option>, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct DailyProductSurfaceUsageResponse { + pub data: Vec, + pub units: Option, + pub data_freshness_ts: Option, + pub group_by: Option, + pub breakdown_by: Option>, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct DailySkillUsageMetricsResponse { + pub data: Vec, + pub data_freshness_ts: Option, + pub group_by: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct DailySkillUsageOverview { + pub date: String, + pub skill_usage_overviews: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct DailyWorkspaceUsageCount { + pub date: String, + pub totals: WorkspaceUsageCount, + pub clients: Vec, + pub models: Option>, + pub groups: Option>, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct DailyWorkspaceUsageCountResponse { + pub data: Vec, + pub balance_unit: Option, + pub active_users_summary: Option, + pub group_by: Option, + pub breakdown_by: Option>, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ModelActivitySummary { + pub model: String, + pub users: i64, + pub threads: i64, + pub turns: i64, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ModelUsage { + pub model: String, + pub speed: Option, + pub credits: f64, + pub cost_usd: Option, + pub users: Option, + pub threads: Option, + pub turns: Option, + pub on_demand_credits: Option, + pub uncached_text_input_tokens: Option, + pub cached_text_input_tokens: Option, + pub text_output_tokens: Option, + pub text_total_tokens: Option, + pub total_tokens: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct PersonalUsageAttribution { + pub thread_source: String, + pub turn_trigger: String, + pub model: String, + pub surface: String, + pub value: f64, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct PluginUsageBucket { + pub date: String, + pub plugin_usage_overviews: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct PluginUsageMetricsResponse { + pub data: Vec, + pub data_freshness_ts: Option, + pub group_by: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct PluginUsageOverview { + pub plugin_id: Option, + pub plugin_name: String, + pub display_name: String, + pub marketplace: Option, + pub invocation_counts: i64, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ProductSurfacePremiumUsageValues { + pub total_usage_credits: std::collections::BTreeMap, + pub credit_usage_credits: std::collections::BTreeMap, + pub uncached_text_input_tokens_by_surface: std::collections::BTreeMap, + pub cached_text_input_tokens_by_surface: std::collections::BTreeMap, + pub text_output_tokens_by_surface: std::collections::BTreeMap, + pub text_total_tokens_by_surface: std::collections::BTreeMap, + pub total_tokens_by_surface: Option>, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct SkillUsageOverview { + pub skill_name: String, + pub display_name: String, + pub skill_ids: Vec, + pub invocation_counts: i64, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct WorkspaceUsageCount { + pub users: i64, + pub threads: i64, + pub turns: i64, + pub credits: f64, + pub cost_usd: Option, + pub on_demand_credits: Option, + pub uncached_text_input_tokens: Option, + pub cached_text_input_tokens: Option, + pub text_output_tokens: Option, + pub text_total_tokens: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct WorkspaceUsageGroup { + pub dimensions: std::collections::BTreeMap, + pub is_other: Option, + pub users: i64, + pub turns: i64, + pub cost_usd: Option, +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/mod.rs b/codex-rs/codex-backend-openapi-models/src/models/mod.rs index b154a044fd..789a5f64d4 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/mod.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod analytics; // Curated minimal export list for current workspace usage. // NOTE: This file was previously auto-generated by the OpenAPI generator. // Currently export only the types referenced by the workspace