mirror of
https://github.com/openai/codex.git
synced 2026-09-17 12:23:33 +00:00
Add account-bound authentication for analytics requests (#45742)
## What changed - Add `AnalyticsSession` to load local ChatGPT credentials, bind requests to the initial account and user, and reject identity changes before requests or when accepting results. - Reload credentials and provide bounded recovery for unauthorized requests, using a backend client that disables redirects. - Add lazy TUI analytics session initialization, account display metadata, and plan-specific credit groupings for future dashboard integration. ## Testing Add regression tests for account and user changes, missing or API-key-only authentication, credential reloads, recovery after a `401` response, and session initialization after signing in. GitOrigin-RevId: 2ba0d96fb44936d12d13aedffc3f2e61aedea560
This commit is contained in:
106
codex-rs/backend-client/src/analytics_session.rs
Normal file
106
codex-rs/backend-client/src/analytics_session.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
//! Account-scoped analytics authentication, credential recovery, and request identity checks.
|
||||
|
||||
use crate::Client;
|
||||
use crate::RequestError;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::AuthManagerConfig;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::account::PlanType;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Non-secret account metadata associated with an analytics session.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AnalyticsAccount {
|
||||
pub id: String,
|
||||
pub email: Option<String>,
|
||||
pub plan_type: Option<PlanType>,
|
||||
}
|
||||
|
||||
/// A backend client that remains bound to its initial ChatGPT account and user.
|
||||
pub struct AnalyticsSession {
|
||||
client: Client,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
auth: CodexAuth,
|
||||
account: AnalyticsAccount,
|
||||
}
|
||||
|
||||
impl AnalyticsSession {
|
||||
/// Load local ChatGPT credentials using the configured auth and HTTP policies.
|
||||
pub async fn from_config(
|
||||
config: &impl AuthManagerConfig,
|
||||
http_client_factory: HttpClientFactory,
|
||||
) -> Result<Self, String> {
|
||||
let auth_manager =
|
||||
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
"Couldn't load local sign-in. Sign in with ChatGPT and retry.".to_string()
|
||||
})?;
|
||||
let auth = auth_manager
|
||||
.auth()
|
||||
.await
|
||||
.filter(CodexAuth::is_chatgpt_auth)
|
||||
.ok_or("Sign in locally with ChatGPT to view Analytics.")?;
|
||||
let (Some(id), Some(_)) = (auth.get_account_id(), auth.get_chatgpt_user_id()) else {
|
||||
return Err("Analytics requires a ChatGPT account and user identity.".into());
|
||||
};
|
||||
let account = AnalyticsAccount {
|
||||
id,
|
||||
email: auth.get_account_email(),
|
||||
plan_type: auth.account_plan_type(),
|
||||
};
|
||||
let client = Client::new_without_redirects(config.chatgpt_base_url(), http_client_factory)
|
||||
.with_auth_provider(codex_model_provider::auth_provider_from_auth_manager(
|
||||
Arc::clone(&auth_manager),
|
||||
&auth,
|
||||
));
|
||||
Ok(Self {
|
||||
client,
|
||||
auth_manager,
|
||||
auth,
|
||||
account,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the account metadata captured when this session was opened.
|
||||
pub fn account(&self) -> &AnalyticsAccount {
|
||||
&self.account
|
||||
}
|
||||
/// Reject responses or cached data after a local account or user switch.
|
||||
pub async fn ensure_identity(&self) -> Result<(), String> {
|
||||
self.auth_manager.reload().await;
|
||||
let current = self.auth_manager.auth().await;
|
||||
if current.is_none_or(|auth| {
|
||||
auth.get_account_id() != self.auth.get_account_id()
|
||||
|| auth.get_chatgpt_user_id() != self.auth.get_chatgpt_user_id()
|
||||
}) {
|
||||
return Err("Account changed. Press R to refresh Analytics.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run an account-scoped request with bounded unauthorized recovery.
|
||||
pub async fn request<T, F>(&self, request: impl Fn(Client) -> F) -> Result<T, RequestError>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, RequestError>>,
|
||||
{
|
||||
let mut recovery = self.auth_manager.unauthorized_recovery();
|
||||
loop {
|
||||
self.ensure_identity()
|
||||
.await
|
||||
.map_err(|error| RequestError::Other(anyhow::anyhow!(error)))?;
|
||||
let result = request(self.client.clone()).await;
|
||||
if result.as_ref().is_err_and(RequestError::is_unauthorized) && recovery.has_next() {
|
||||
recovery.next().await.map_err(|_| {
|
||||
RequestError::Other(anyhow::anyhow!("Sign in again to view Analytics."))
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
self.ensure_identity()
|
||||
.await
|
||||
.map_err(|error| RequestError::Other(anyhow::anyhow!(error)))?;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
mod analytics_session;
|
||||
mod client;
|
||||
pub(crate) mod types;
|
||||
|
||||
pub use analytics_session::AnalyticsAccount;
|
||||
pub use analytics_session::AnalyticsSession;
|
||||
pub use client::AddCreditsNudgeCreditType;
|
||||
pub use client::ChatgptThreadTurnCosts;
|
||||
pub use client::ChatgptTurnCost;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Account analytics data preparation, staged for the dashboard integration.
|
||||
|
||||
mod client;
|
||||
mod data;
|
||||
mod models;
|
||||
mod normalize;
|
||||
|
||||
73
codex-rs/tui/src/analytics/client.rs
Normal file
73
codex-rs/tui/src/analytics/client.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
//! Local account sessions and display metadata for authenticated analytics.
|
||||
//! The backend client owns credentials, recovery, and request identity checks.
|
||||
|
||||
use super::models::AccountAnalyticsGrouping as Grouping;
|
||||
use super::models::AccountKind;
|
||||
use crate::legacy_core::config::Config;
|
||||
use codex_backend_client::AnalyticsSession;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
pub(super) struct Live {
|
||||
config: Arc<Config>,
|
||||
session: OnceCell<Session>,
|
||||
}
|
||||
|
||||
pub(super) struct Session {
|
||||
pub(super) kind: AccountKind,
|
||||
pub(super) backend: AnalyticsSession,
|
||||
credit_groups: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Live {
|
||||
pub(super) fn new(config: Arc<Config>) -> Self {
|
||||
Self {
|
||||
config,
|
||||
session: OnceCell::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn account_label(&self) -> Option<String> {
|
||||
let session = self.session.get()?;
|
||||
let account = session.backend.account();
|
||||
Some(match &account.email {
|
||||
Some(email) => format!("{email} · {}", account.id),
|
||||
None => account.id.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn credit_groups(&self) -> &[usize] {
|
||||
self.session
|
||||
.get()
|
||||
.map_or(&[0], |session| &session.credit_groups)
|
||||
}
|
||||
|
||||
pub(super) async fn session(&self) -> Result<&Session, String> {
|
||||
self.session
|
||||
.get_or_try_init(|| async {
|
||||
let session = AnalyticsSession::from_config(
|
||||
self.config.as_ref(),
|
||||
self.config.http_client_factory(),
|
||||
)
|
||||
.await?;
|
||||
let credit_groups = Grouping::credit_groupings(session.account().plan_type)
|
||||
.iter()
|
||||
.filter_map(|group| {
|
||||
super::data::GROUPINGS
|
||||
.iter()
|
||||
.position(|candidate| candidate == group)
|
||||
})
|
||||
.collect();
|
||||
Ok(Session {
|
||||
kind: AccountKind::from(session.account().plan_type),
|
||||
credit_groups,
|
||||
backend: session,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "client_tests.rs"]
|
||||
pub(super) mod tests;
|
||||
223
codex-rs/tui/src/analytics/client_tests.rs
Normal file
223
codex-rs/tui/src/analytics/client_tests.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
//! Account-scoped authentication and request identity regression coverage.
|
||||
|
||||
use super::*;
|
||||
use crate::legacy_core::config::ConfigBuilder;
|
||||
use base64::Engine;
|
||||
use codex_config::LoaderOverrides;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
fn sign_in(home: &std::path::Path, account: &str, user: &str, plan: &str) {
|
||||
let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
|
||||
json!({
|
||||
"exp": 4102444800_i64, "email": "analytics@example.test",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": account, "chatgpt_user_id": user, "chatgpt_plan_type": plan,
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
let token = format!("e30.{claims}.test");
|
||||
let auth = serde_json::from_value(json!({
|
||||
"auth_mode": "chatgpt", "tokens": {"id_token": token, "access_token": token,
|
||||
"refresh_token": "test-refresh", "account_id": account},
|
||||
"last_refresh": chrono::Utc::now(),
|
||||
}))
|
||||
.unwrap();
|
||||
codex_login::save_auth(
|
||||
home,
|
||||
&auth,
|
||||
codex_login::AuthCredentialsStoreMode::File,
|
||||
codex_login::AuthKeyringBackendKind::default(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub(in crate::analytics) async fn live(
|
||||
server: &MockServer,
|
||||
plan: &str,
|
||||
) -> (tempfile::TempDir, Live) {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
|
||||
.build()
|
||||
.await
|
||||
.unwrap();
|
||||
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
|
||||
config.cli_auth_credentials_store_mode = codex_login::AuthCredentialsStoreMode::File;
|
||||
sign_in(home.path(), "account-a", "user-a", plan);
|
||||
(home, Live::new(Arc::new(config)))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn analytics_rejects_identity_changes_during_requests() {
|
||||
let server = MockServer::start().await;
|
||||
let (home, live) = live(&server, "business").await;
|
||||
let session = live.session().await.unwrap();
|
||||
let authenticated = &session.backend;
|
||||
let result = authenticated
|
||||
.request(|_| async {
|
||||
sign_in(home.path(), "account-b", "user-a", "business");
|
||||
Ok(123)
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
result.unwrap_err().to_string(),
|
||||
"Account changed. Press R to refresh Analytics."
|
||||
);
|
||||
assert!(server.received_requests().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn analytics_rejects_account_and_user_changes_before_requests() {
|
||||
for (account, user) in [("account-b", "user-a"), ("account-a", "user-b")] {
|
||||
let server = MockServer::start().await;
|
||||
let (home, live) = live(&server, "plus").await;
|
||||
let session = live.session().await.unwrap();
|
||||
let authenticated = &session.backend;
|
||||
sign_in(home.path(), account, user, "plus");
|
||||
let requested = std::cell::Cell::new(/*value*/ false);
|
||||
let result = authenticated
|
||||
.request(|_| async {
|
||||
requested.set(/*val*/ true);
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
assert!(!requested.get());
|
||||
assert_eq!(
|
||||
result.unwrap_err().to_string(),
|
||||
"Account changed. Press R to refresh Analytics."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn analytics_requires_local_chatgpt_authentication() {
|
||||
for auth in [None, Some(json!({"OPENAI_API_KEY": "sk-test-only"}))] {
|
||||
let server = MockServer::start().await;
|
||||
let (home, live) = live(&server, "plus").await;
|
||||
let path = home.path().join("auth.json");
|
||||
match auth {
|
||||
Some(auth) => std::fs::write(path, serde_json::to_vec(&auth).unwrap()).unwrap(),
|
||||
None => std::fs::remove_file(path).unwrap(),
|
||||
}
|
||||
assert_eq!(
|
||||
live.session().await.err(),
|
||||
Some("Sign in locally with ChatGPT to view Analytics.".to_string())
|
||||
);
|
||||
assert!(server.received_requests().await.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn analytics_requests_use_reloaded_credentials_for_the_same_identity() {
|
||||
let server = MockServer::start().await;
|
||||
let (home, live) = live(&server, "plus").await;
|
||||
let session = live.session().await.unwrap();
|
||||
let authenticated = &session.backend;
|
||||
assert_eq!(
|
||||
live.account_label().as_deref(),
|
||||
Some("analytics@example.test · account-a")
|
||||
);
|
||||
let auth_path = home.path().join("auth.json");
|
||||
let mut auth: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap();
|
||||
auth["tokens"]["access_token"] = json!("refreshed-access-token");
|
||||
std::fs::write(auth_path, serde_json::to_vec(&auth).unwrap()).unwrap();
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/wham/usage/daily-token-usage-breakdown"))
|
||||
.and(header("chatgpt-account-id", "account-a"))
|
||||
.and(header("authorization", "Bearer refreshed-access-token"))
|
||||
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": []})))
|
||||
.expect(/*r*/ 1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let result = authenticated
|
||||
.request(|client| async move {
|
||||
client
|
||||
.get_account_analytics(
|
||||
codex_backend_client::AnalyticsReport::Usage,
|
||||
"2026-09-01",
|
||||
"2026-09-07",
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
codex_backend_client::AnalyticsResponse::Usage(
|
||||
codex_backend_client::analytics_models::DailyProductSurfaceUsageResponse::default(),
|
||||
),
|
||||
);
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn analytics_retries_unauthorized_requests_after_credentials_reload() {
|
||||
let server = MockServer::start().await;
|
||||
let (home, live) = live(&server, "plus").await;
|
||||
let session = live.session().await.unwrap();
|
||||
let authenticated = &session.backend;
|
||||
let auth_path = home.path().join("auth.json");
|
||||
let mut auth: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(&auth_path).unwrap()).unwrap();
|
||||
let original_token = auth["tokens"]["access_token"].as_str().unwrap().to_owned();
|
||||
auth["tokens"]["access_token"] = json!("recovered-access-token");
|
||||
let updated_auth = serde_json::to_vec(&auth).unwrap();
|
||||
Mock::given(method("GET"))
|
||||
.and(header("authorization", format!("Bearer {original_token}")))
|
||||
.respond_with(move |_: &wiremock::Request| {
|
||||
std::fs::write(&auth_path, &updated_auth).unwrap();
|
||||
ResponseTemplate::new(/*s*/ 401)
|
||||
})
|
||||
.expect(/*r*/ 1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(header("authorization", "Bearer recovered-access-token"))
|
||||
.and(header("chatgpt-account-id", "account-a"))
|
||||
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({"data": []})))
|
||||
.expect(/*r*/ 1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let result = authenticated
|
||||
.request(|client| async move {
|
||||
client
|
||||
.get_account_analytics(
|
||||
codex_backend_client::AnalyticsReport::Usage,
|
||||
"2026-09-01",
|
||||
"2026-09-07",
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
codex_backend_client::AnalyticsResponse::Usage(
|
||||
codex_backend_client::analytics_models::DailyProductSurfaceUsageResponse::default(),
|
||||
),
|
||||
);
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn analytics_retries_session_initialization_after_sign_in() {
|
||||
let server = MockServer::start().await;
|
||||
let (home, live) = live(&server, "plus").await;
|
||||
std::fs::remove_file(home.path().join("auth.json")).unwrap();
|
||||
assert!(live.session().await.is_err());
|
||||
sign_in(home.path(), "account-a", "user-a", "plus");
|
||||
assert_eq!(
|
||||
live.session().await.unwrap().backend.account().id,
|
||||
"account-a"
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,16 @@
|
||||
//! Precise signed credit amounts and dates for analytics presentation.
|
||||
//! Grouping metadata and numeric formatting for account analytics.
|
||||
|
||||
use super::models::AccountAnalyticsGrouping as Grouping;
|
||||
|
||||
pub(super) const GROUPINGS: [Grouping; 7] = [
|
||||
Grouping::Surface,
|
||||
Grouping::Feature,
|
||||
Grouping::Model,
|
||||
Grouping::TaskStart,
|
||||
Grouping::Speed,
|
||||
Grouping::Reasoning,
|
||||
Grouping::TokenType,
|
||||
];
|
||||
|
||||
/// Keep tiny refunds visible while avoiding noise on ordinary credit amounts.
|
||||
pub(super) fn amount(value: f64) -> String {
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
//! Private analytics display types, independent of the app-server wire protocol.
|
||||
|
||||
use codex_protocol::account::PlanType;
|
||||
|
||||
/// Billing families match the App's consumer, business, and workspace scopes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum AccountKind {
|
||||
Consumer,
|
||||
Business,
|
||||
Enterprise,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl From<Option<PlanType>> for AccountKind {
|
||||
fn from(plan: Option<PlanType>) -> Self {
|
||||
match plan {
|
||||
None | Some(PlanType::Unknown) => Self::Unknown,
|
||||
Some(plan) if plan.is_team_like() => Self::Business,
|
||||
Some(plan) if plan.is_workspace_account() => Self::Enterprise,
|
||||
Some(_) => Self::Consumer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AccountAnalyticsReport {
|
||||
Usage,
|
||||
@@ -20,6 +42,17 @@ pub(crate) enum AccountAnalyticsGrouping {
|
||||
TokenType,
|
||||
}
|
||||
|
||||
impl AccountAnalyticsGrouping {
|
||||
/// Credit breakdowns supported by the account's billing report.
|
||||
pub(crate) fn credit_groupings(plan: Option<PlanType>) -> &'static [Self] {
|
||||
match AccountKind::from(plan) {
|
||||
AccountKind::Business => &[Self::Surface, Self::Model, Self::Speed],
|
||||
AccountKind::Enterprise => &[Self::Surface, Self::Model, Self::Speed, Self::Reasoning],
|
||||
AccountKind::Consumer | AccountKind::Unknown => &[Self::Surface],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AccountAnalyticsUnit {
|
||||
RelativeUsage,
|
||||
|
||||
Reference in New Issue
Block a user