feat(app-server): expose account token usage

This commit is contained in:
Felipe Coury
2026-05-30 19:17:53 -03:00
parent 966932124c
commit 4ebef9107d
18 changed files with 555 additions and 1 deletions
+1
View File
@@ -1760,6 +1760,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `account/logout` — sign out; triggers `account/updated`.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify).
- `account/tokenUsage/read` — fetch ChatGPT account token-activity summary and daily buckets.
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change.
- `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit.
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`.
@@ -1280,6 +1280,9 @@ impl MessageProcessor {
ClientRequest::GetAccountRateLimits { .. } => {
self.account_processor.get_account_rate_limits().await
}
ClientRequest::GetAccountTokenUsage { .. } => {
self.account_processor.get_account_token_usage().await
}
ClientRequest::SendAddCreditsNudgeEmail { params, .. } => {
self.account_processor
.send_add_credits_nudge_email(params)
@@ -22,6 +22,8 @@ use codex_analytics::InputError;
use codex_analytics::TurnSteerRequestError;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_app_server_protocol::AccountTokenUsageDailyBucket;
use codex_app_server_protocol::AccountTokenUsageSummary;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
@@ -63,6 +65,7 @@ use codex_app_server_protocol::FeedbackUploadResponse;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::GetAccountResponse;
use codex_app_server_protocol::GetAccountTokenUsageResponse;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
use codex_app_server_protocol::GetConversationSummaryParams;
@@ -265,6 +268,7 @@ use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
use codex_arg0::Arg0DispatchPaths;
use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType;
use codex_backend_client::Client as BackendClient;
use codex_backend_client::TokenUsageProfile;
use codex_chatgpt::connectors;
use codex_chatgpt::workspace_settings;
use codex_config::CloudRequirementsLoadError;
@@ -2,6 +2,7 @@ use super::*;
// Duration before a browser ChatGPT login attempt is abandoned.
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
// The override is intentionally available only in debug builds, matching the login path below.
#[cfg(debug_assertions)]
const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
@@ -131,6 +132,14 @@ impl AccountRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn get_account_token_usage(
&self,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.get_account_token_usage_response()
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn send_add_credits_nudge_email(
&self,
params: SendAddCreditsNudgeEmailParams,
@@ -848,6 +857,55 @@ impl AccountRequestProcessor {
)
}
async fn get_account_token_usage_response(
&self,
) -> Result<GetAccountTokenUsageResponse, JSONRPCErrorError> {
let Some(auth) = self.auth_manager.auth().await else {
return Err(invalid_request(
"codex account authentication required to read token usage",
));
};
if !auth.uses_codex_backend() {
return Err(invalid_request(
"chatgpt authentication required to read token usage",
));
}
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
let profile = tokio::time::timeout(
ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT,
client.get_token_usage_profile(),
)
.await
.map_err(|_| internal_error("token usage profile fetch timed out"))?
.map_err(|err| internal_error(format!("failed to fetch token usage profile: {err}")))?;
Ok(Self::account_token_usage_response(profile))
}
fn account_token_usage_response(profile: TokenUsageProfile) -> GetAccountTokenUsageResponse {
let stats = profile.stats;
GetAccountTokenUsageResponse {
summary: AccountTokenUsageSummary {
lifetime_tokens: stats.lifetime_tokens,
peak_daily_tokens: stats.peak_daily_tokens,
longest_running_turn_sec: stats.longest_running_turn_sec,
current_streak_days: stats.current_streak_days,
longest_streak_days: stats.longest_streak_days,
},
daily_usage_buckets: stats.daily_usage_buckets.map(|buckets| {
buckets
.into_iter()
.map(|bucket| AccountTokenUsageDailyBucket {
start_date: bucket.start_date,
tokens: bucket.tokens,
})
.collect()
}),
}
}
async fn send_add_credits_nudge_email_response(
&self,
params: SendAddCreditsNudgeEmailParams,
@@ -952,3 +1010,45 @@ impl AccountRequestProcessor {
Ok((primary, rate_limits_by_limit_id))
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_backend_client::TokenUsageProfileDailyBucket;
use codex_backend_client::TokenUsageProfileStats;
use pretty_assertions::assert_eq;
#[test]
fn account_token_usage_response_maps_profile_stats_and_daily_buckets() {
let response = AccountRequestProcessor::account_token_usage_response(TokenUsageProfile {
stats: TokenUsageProfileStats {
lifetime_tokens: Some(123),
peak_daily_tokens: Some(45),
longest_running_turn_sec: Some(67),
current_streak_days: Some(8),
longest_streak_days: Some(9),
daily_usage_buckets: Some(vec![TokenUsageProfileDailyBucket {
start_date: "2026-05-29".to_string(),
tokens: 10,
}]),
},
});
assert_eq!(
response,
GetAccountTokenUsageResponse {
summary: AccountTokenUsageSummary {
lifetime_tokens: Some(123),
peak_daily_tokens: Some(45),
longest_running_turn_sec: Some(67),
current_streak_days: Some(8),
longest_streak_days: Some(9),
},
daily_usage_buckets: Some(vec![AccountTokenUsageDailyBucket {
start_date: "2026-05-29".to_string(),
tokens: 10,
}]),
}
);
}
}