File size: 5,033 Bytes
afa0cbf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | //! 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;
|