mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
## 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
134 lines
4.9 KiB
Rust
134 lines
4.9 KiB
Rust
//! 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;
|
|
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 bound to its initial ChatGPT account and user, with a server plan snapshot.
|
|
pub struct AnalyticsSession {
|
|
client: Client,
|
|
auth_manager: Arc<AuthManager>,
|
|
auth: CodexAuth,
|
|
account: AnalyticsAccount,
|
|
}
|
|
|
|
impl AnalyticsSession {
|
|
/// 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,
|
|
) -> 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: 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,
|
|
));
|
|
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 change.
|
|
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;
|
|
// 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()
|
|
.await
|
|
.map_err(|error| RequestError::Other(anyhow::anyhow!(error)))?;
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "analytics_session_tests.rs"]
|
|
mod tests;
|