mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +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:
@@ -1194,11 +1194,15 @@ impl AccountRequestProcessor {
|
||||
|
||||
// Match desktop's account readiness check before exposing account-bound CTA content.
|
||||
// Normal rate limits remain available when older backends omit identity or banner data.
|
||||
let matches_active_account = !auth.is_fedramp_account()
|
||||
&& response.account_id.is_some()
|
||||
&& response.account_id == auth.get_account_id()
|
||||
&& response.user_id.is_some()
|
||||
&& response.user_id == auth.get_chatgpt_user_id();
|
||||
// Login can change while the backend read is in flight.
|
||||
let active_auth = self.auth_manager.auth().await;
|
||||
let matches_active_account = active_auth.is_some_and(|auth| {
|
||||
!auth.is_fedramp_account()
|
||||
&& response.account_id.is_some()
|
||||
&& response.account_id == auth.get_account_id()
|
||||
&& response.user_id.is_some()
|
||||
&& response.user_id == auth.get_chatgpt_user_id()
|
||||
});
|
||||
let rate_limit_upsell = response
|
||||
.rate_limit_upsell
|
||||
.filter(|_| matches_active_account);
|
||||
|
||||
@@ -91,6 +91,8 @@ mod process_exec;
|
||||
mod projects;
|
||||
mod rate_limit_reset_credits;
|
||||
mod rate_limits;
|
||||
#[path = "rate_limits_identity_tests.rs"]
|
||||
mod rate_limits_identity;
|
||||
mod realtime_conversation;
|
||||
mod recommended_plugins;
|
||||
mod remote_control;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Account readiness is verified after an in-flight backend read, including concurrent login.
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::ChatGptIdTokenClaims;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::encode_id_token;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::routing::get;
|
||||
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
||||
use codex_app_server_protocol::LoginAccountResponse;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use test_case::test_case;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
|
||||
|
||||
#[test_case("workspace-a", "user-b", /*verified*/ false; "user_switch")]
|
||||
#[test_case("workspace-b", "user-a", /*verified*/ false; "workspace_switch")]
|
||||
#[test_case("workspace-a", "user-a", /*verified*/ true; "same_identity_token_refresh")]
|
||||
#[tokio::test]
|
||||
async fn identity_is_rechecked_after_backend_response(
|
||||
account: &str,
|
||||
user: &str,
|
||||
verified: bool,
|
||||
) -> Result<()> {
|
||||
let home = TempDir::new()?;
|
||||
write_chatgpt_auth(
|
||||
home.path(),
|
||||
ChatGptAuthFixture::new("old-token")
|
||||
.account_id("workspace-a")
|
||||
.chatgpt_user_id("user-a")
|
||||
.plan_type("team"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
let entered = Arc::new(Notify::new());
|
||||
let release = Arc::new(Notify::new());
|
||||
let request_entered = Arc::clone(&entered);
|
||||
let response_release = Arc::clone(&release);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!("chatgpt_base_url = \"http://{}\"\n", listener.local_addr()?),
|
||||
)?;
|
||||
let router = Router::new().route(
|
||||
"/api/codex/usage",
|
||||
get(move |headers: HeaderMap| {
|
||||
let entered = Arc::clone(&request_entered);
|
||||
let release = Arc::clone(&response_release);
|
||||
async move {
|
||||
assert_eq!(headers["authorization"], "Bearer old-token");
|
||||
assert_eq!(headers["chatgpt-account-id"], "workspace-a");
|
||||
entered.notify_one();
|
||||
release.notified().await;
|
||||
Json(json!({
|
||||
"account_id": "workspace-a", "user_id": "user-a", "plan_type": "team",
|
||||
"rate_limit": {"allowed": true, "limit_reached": false,
|
||||
"primary_window": {"used_percent": 42, "limit_window_seconds": 3600,
|
||||
"reset_after_seconds": 120, "reset_at": 2000000000}}
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
let server = tokio::spawn(async move { axum::serve(listener, router).await });
|
||||
let mut app = TestAppServer::builder()
|
||||
.with_codex_home(home.path())
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.build_initialized_with_timeout(READ_TIMEOUT)
|
||||
.await?;
|
||||
let request = app
|
||||
.send_request(
|
||||
"account/rateLimits/read",
|
||||
Some(json!({"excludeResetCreditDetails": true})),
|
||||
)
|
||||
.await?;
|
||||
timeout(READ_TIMEOUT, entered.notified()).await?;
|
||||
let token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
.chatgpt_account_id(account)
|
||||
.chatgpt_user_id(user)
|
||||
.plan_type("team"),
|
||||
)?;
|
||||
let login = app
|
||||
.send_chatgpt_auth_tokens_login_request(token, account.into(), Some("team".into()))
|
||||
.await?;
|
||||
let response: LoginAccountResponse = timeout(READ_TIMEOUT, app.read_response(login)).await??;
|
||||
assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});
|
||||
release.notify_one();
|
||||
let response: GetAccountRateLimitsResponse =
|
||||
timeout(READ_TIMEOUT, app.read_response(request)).await??;
|
||||
server.abort();
|
||||
let snapshot = json!({
|
||||
"limitId": "codex", "planType": "team",
|
||||
"primary": {"usedPercent": 42, "windowDurationMins": 60, "resetsAt": 2000000000}
|
||||
});
|
||||
let expected: GetAccountRateLimitsResponse = serde_json::from_value(json!({
|
||||
"ordinaryUsageAllowed": if verified { Some(true) } else { None },
|
||||
"accountId": "workspace-a",
|
||||
"rateLimits": snapshot, "rateLimitsByLimitId": {"codex": snapshot}
|
||||
}))?;
|
||||
assert_eq!(response, expected);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user