mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user