Load analytics reports with server plans and account identity checks (#45762)

## Why

Token plan claims can be stale, and the active account or user can change while a request is in flight. Analytics report selection and account-bound response data need to reflect the verified identity and current server plan.

## What changed

- Fetch the active account's plan once per analytics session and use it to select report endpoints and supported credit breakdowns.
- Add report loading with a fixed end date, account-scoped response caching, and token model filtering. Reuse payloads across grouping changes and evict invalid cached responses so requests can retry.
- Prefer complete attribution for usage breakdowns within the requested range; retain legacy surface/model data when attribution is incomplete and include all features in turn-start breakdowns.
- Add cancellable report-loading state with timeout and interruption errors, and preserve actionable sign-in and retry messages.
- Recheck the active identity after rate-limit reads before exposing account-bound fields.

## Testing

Add regression tests for server plan discovery, report routing and caching, model filtering, attribution fallback, failed-request retries, load cancellation, authentication recovery, and account or user changes during requests.

GitOrigin-RevId: 45c4c09f108c9703893f3ed15613437ebd7c74a8
This commit is contained in:
Felipe Coury
2026-09-15 19:00:56 +00:00
committed by copyberry
parent aaa2cabfbc
commit 1fc46a532b
17 changed files with 1477 additions and 63 deletions
@@ -1,4 +1,5 @@
//! Account-scoped analytics authentication, credential recovery, and request identity checks.
//! Local analytics authentication, server plan discovery, and account/user identity checks.
//! Each new session fetches its plan once; token plan claims do not select reports.
use crate::Client;
use crate::RequestError;
@@ -17,7 +18,7 @@ pub struct AnalyticsAccount {
pub plan_type: Option<PlanType>,
}
/// A backend client that remains bound to its initial ChatGPT account and user.
/// A backend client bound to its initial ChatGPT account and user, with a server plan snapshot.
pub struct AnalyticsSession {
client: Client,
auth_manager: Arc<AuthManager>,
@@ -26,7 +27,7 @@ pub struct AnalyticsSession {
}
impl AnalyticsSession {
/// Load local ChatGPT credentials using the configured auth and HTTP policies.
/// Load local ChatGPT credentials and the current server plan using the configured HTTP policy.
pub async fn from_config(
config: &impl AuthManagerConfig,
http_client_factory: HttpClientFactory,
@@ -48,26 +49,47 @@ impl AnalyticsSession {
let account = AnalyticsAccount {
id,
email: auth.get_account_email(),
plan_type: auth.account_plan_type(),
plan_type: None,
};
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 {
let mut session = Self {
client,
auth_manager,
auth,
account,
})
};
let accounts = session
.request(|client| async move { client.get_accounts_check().await })
.await;
// Preserve account-switch guidance even when it interrupts account discovery.
session.ensure_identity().await?;
let accounts = accounts.map_err(|error| {
if error.is_unauthorized() {
"Sign in again to load Analytics."
} else {
"Couldn't load account plan. Press R to retry Analytics."
}
})?;
session.account.plan_type = Some(
accounts
.accounts
.into_iter()
.find(|account| account.id == session.account.id)
.and_then(|account| account.plan_type)
.ok_or("Couldn't load account plan. Press R to retry Analytics.")?,
);
Ok(session)
}
/// 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.
/// Reject responses or cached data after a local account or user change.
pub async fn ensure_identity(&self) -> Result<(), String> {
self.auth_manager.reload().await;
let current = self.auth_manager.auth().await;
@@ -91,10 +113,11 @@ impl AnalyticsSession {
.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."))
})?;
// Keep the original 401 when recovery fails so callers retain sign-in guidance.
if result.as_ref().is_err_and(RequestError::is_unauthorized)
&& recovery.has_next()
&& recovery.next().await.is_ok()
{
continue;
}
self.ensure_identity()
@@ -104,3 +127,7 @@ impl AnalyticsSession {
}
}
}
#[cfg(test)]
#[path = "analytics_session_tests.rs"]
mod tests;
@@ -0,0 +1,113 @@
//! Authentication recovery preserves actionable status without changing the account scope.
use super::*;
use codex_http_client::OutboundProxyPolicy;
use codex_login::ExternalAuth;
use codex_login::ExternalAuthFuture;
use codex_login::ExternalAuthRefreshContext;
use codex_login::RefreshTokenError;
use codex_protocol::auth::RefreshTokenFailedError;
use codex_protocol::auth::RefreshTokenFailedReason;
use pretty_assertions::assert_eq;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
enum RefreshOutcome {
Accepted,
Rejected,
}
struct ExternalCredentials {
auth: CodexAuth,
outcome: RefreshOutcome,
refreshes: AtomicUsize,
}
impl ExternalAuth for ExternalCredentials {
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
Box::pin(async { Ok(self.auth.clone()) })
}
fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> {
Box::pin(async {
self.refreshes.fetch_add(/*val*/ 1, Ordering::SeqCst);
match self.outcome {
RefreshOutcome::Accepted => Ok(self.auth.clone()),
RefreshOutcome::Rejected => Err(std::io::Error::other("refresh rejected")),
}
})
}
fn classify_error(&self, error: std::io::Error) -> RefreshTokenError {
RefreshTokenError::Permanent(RefreshTokenFailedError::new(
RefreshTokenFailedReason::Other,
error.to_string(),
))
}
}
#[tokio::test]
async fn failed_recovery_preserves_unauthorized_status() {
assert_unauthorized_after_recovery(RefreshOutcome::Rejected, /*expected_requests*/ 1).await;
}
#[tokio::test]
async fn persistent_unauthorized_stops_after_successful_recovery() {
assert_unauthorized_after_recovery(RefreshOutcome::Accepted, /*expected_requests*/ 2).await;
}
async fn assert_unauthorized_after_recovery(outcome: RefreshOutcome, expected_requests: u64) {
let auth = CodexAuth::from_external_chatgpt_tokens(
"e30.eyJleHAiOjQxMDI0NDQ4MDAsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyLWEifX0.test", "account-a", Some("plus"),
).unwrap();
let auth_manager = AuthManager::from_auth_for_testing(auth.clone());
let credentials = Arc::new(ExternalCredentials {
auth: auth.clone(),
outcome,
refreshes: AtomicUsize::new(/*v*/ 0),
});
auth_manager
.set_external_auth(credentials.clone())
.await
.unwrap();
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(/*s*/ 401))
.expect(expected_requests)
.mount(&server)
.await;
let client = Client::new_without_redirects(
server.uri(),
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
)
.with_auth_provider(codex_model_provider::auth_provider_from_auth_manager(
Arc::clone(&auth_manager),
&auth,
));
let session = AnalyticsSession {
account: AnalyticsAccount {
id: "account-a".into(),
email: None,
plan_type: auth.account_plan_type(),
},
client,
auth_manager,
auth,
};
let error = session
.request(|client| async move {
client
.get_account_analytics(crate::AnalyticsReport::Usage, "2026-09-01", "2026-09-07")
.await
})
.await
.unwrap_err();
assert_eq!(error.status().map(|status| status.as_u16()), Some(401));
session.ensure_identity().await.unwrap();
assert_eq!(credentials.refreshes.load(Ordering::SeqCst), 1);
server.verify().await;
}
+6 -3
View File
@@ -339,14 +339,17 @@ impl Client {
Ok(self.get_rate_limits_with_reset_credits().await?.rate_limits)
}
pub async fn get_accounts_check(&self) -> Result<AccountsCheckResponse> {
pub async fn get_accounts_check(
&self,
) -> std::result::Result<AccountsCheckResponse, RequestError> {
let url = match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/accounts/check", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/accounts/check", self.base_url),
};
let req = self.request(Method::GET, &url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json(&url, &ct, &body)
let (body, _) = self.exec_request_detailed(req, "GET", &url).await?;
serde_json::from_str(&body)
.map_err(|_| RequestError::Other(anyhow::anyhow!("Invalid accounts response.")))
}
pub async fn get_token_usage_profile(&self) -> Result<TokenUsageProfile> {
+6
View File
@@ -136,6 +136,9 @@ pub struct AccountsCheckResponse {
#[derive(Clone, Debug, Deserialize)]
pub struct AccountEntry {
pub id: String,
/// Current subscription reported by the accounts endpoint, independent of token claims.
#[serde(default)]
pub plan_type: Option<codex_protocol::account::PlanType>,
pub workspace_backend_origin: Option<String>,
pub account_routing_override: Option<String>,
#[serde(default)]
@@ -178,6 +181,8 @@ struct ChatGptAccountEntry {
struct ChatGptAccountInfo {
account_id: Option<String>,
#[serde(default)]
plan_type: Option<codex_protocol::account::PlanType>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
profile_picture_url: Option<String>,
@@ -200,6 +205,7 @@ impl<'de> Deserialize<'de> for AccountsCheckResponse {
let account = accounts.remove(account_id)?.account;
Some(AccountEntry {
id: account.account_id?,
plan_type: account.plan_type,
workspace_backend_origin: None,
account_routing_override: None,
name: account.name,