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

View File

@@ -5921,6 +5921,29 @@
"title": "Account/rateLimits/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"account/tokenUsage/read"
],
"title": "Account/tokenUsage/readRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "Account/tokenUsage/readRequest",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -1833,6 +1833,29 @@
"title": "Account/rateLimits/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"account/tokenUsage/read"
],
"title": "Account/tokenUsage/readRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "Account/tokenUsage/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -5731,6 +5754,62 @@
"title": "AccountRateLimitsUpdatedNotification",
"type": "object"
},
"AccountTokenUsageDailyBucket": {
"properties": {
"startDate": {
"type": "string"
},
"tokens": {
"format": "int64",
"type": "integer"
}
},
"required": [
"startDate",
"tokens"
],
"type": "object"
},
"AccountTokenUsageSummary": {
"properties": {
"currentStreakDays": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"lifetimeTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"longestRunningTurnSec": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"longestStreakDays": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"peakDailyTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"AccountUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -9474,6 +9553,28 @@
"title": "GetAccountResponse",
"type": "object"
},
"GetAccountTokenUsageResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"dailyUsageBuckets": {
"items": {
"$ref": "#/definitions/v2/AccountTokenUsageDailyBucket"
},
"type": [
"array",
"null"
]
},
"summary": {
"$ref": "#/definitions/v2/AccountTokenUsageSummary"
}
},
"required": [
"summary"
],
"title": "GetAccountTokenUsageResponse",
"type": "object"
},
"GitInfo": {
"properties": {
"branch": {

View File

@@ -103,6 +103,62 @@
"title": "AccountRateLimitsUpdatedNotification",
"type": "object"
},
"AccountTokenUsageDailyBucket": {
"properties": {
"startDate": {
"type": "string"
},
"tokens": {
"format": "int64",
"type": "integer"
}
},
"required": [
"startDate",
"tokens"
],
"type": "object"
},
"AccountTokenUsageSummary": {
"properties": {
"currentStreakDays": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"lifetimeTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"longestRunningTurnSec": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"longestStreakDays": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"peakDailyTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"AccountUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -2581,6 +2637,29 @@
"title": "Account/rateLimits/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"account/tokenUsage/read"
],
"title": "Account/tokenUsage/readRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "Account/tokenUsage/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -5954,6 +6033,28 @@
"title": "GetAccountResponse",
"type": "object"
},
"GetAccountTokenUsageResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"dailyUsageBuckets": {
"items": {
"$ref": "#/definitions/AccountTokenUsageDailyBucket"
},
"type": [
"array",
"null"
]
},
"summary": {
"$ref": "#/definitions/AccountTokenUsageSummary"
}
},
"required": [
"summary"
],
"title": "GetAccountTokenUsageResponse",
"type": "object"
},
"GitInfo": {
"properties": {
"branch": {

View File

@@ -0,0 +1,80 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AccountTokenUsageDailyBucket": {
"properties": {
"startDate": {
"type": "string"
},
"tokens": {
"format": "int64",
"type": "integer"
}
},
"required": [
"startDate",
"tokens"
],
"type": "object"
},
"AccountTokenUsageSummary": {
"properties": {
"currentStreakDays": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"lifetimeTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"longestRunningTurnSec": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"longestStreakDays": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"peakDailyTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"properties": {
"dailyUsageBuckets": {
"items": {
"$ref": "#/definitions/AccountTokenUsageDailyBucket"
},
"type": [
"array",
"null"
]
},
"summary": {
"$ref": "#/definitions/AccountTokenUsageSummary"
}
},
"required": [
"summary"
],
"title": "GetAccountTokenUsageResponse",
"type": "object"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AccountTokenUsageDailyBucket = { startDate: string, tokens: bigint, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AccountTokenUsageSummary = { lifetimeTokens: bigint | null, peakDailyTokens: bigint | null, longestRunningTurnSec: bigint | null, currentStreakDays: bigint | null, longestStreakDays: bigint | null, };

View File

@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AccountTokenUsageDailyBucket } from "./AccountTokenUsageDailyBucket";
import type { AccountTokenUsageSummary } from "./AccountTokenUsageSummary";
export type GetAccountTokenUsageResponse = { summary: AccountTokenUsageSummary, dailyUsageBuckets: Array<AccountTokenUsageDailyBucket> | null, };

View File

@@ -3,6 +3,8 @@
export type { Account } from "./Account";
export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification";
export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification";
export type { AccountTokenUsageDailyBucket } from "./AccountTokenUsageDailyBucket";
export type { AccountTokenUsageSummary } from "./AccountTokenUsageSummary";
export type { AccountUpdatedNotification } from "./AccountUpdatedNotification";
export type { ActivePermissionProfile } from "./ActivePermissionProfile";
export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType";
@@ -139,6 +141,7 @@ export type { FsWriteFileResponse } from "./FsWriteFileResponse";
export type { GetAccountParams } from "./GetAccountParams";
export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse";
export type { GetAccountResponse } from "./GetAccountResponse";
export type { GetAccountTokenUsageResponse } from "./GetAccountTokenUsageResponse";
export type { GitInfo } from "./GitInfo";
export type { GrantedPermissionProfile } from "./GrantedPermissionProfile";
export type { GuardianApprovalReview } from "./GuardianApprovalReview";

View File

@@ -931,6 +931,12 @@ client_request_definitions! {
response: v2::GetAccountRateLimitsResponse,
},
GetAccountTokenUsage => "account/tokenUsage/read" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: None,
response: v2::GetAccountTokenUsageResponse,
},
SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" {
params: v2::SendAddCreditsNudgeEmailParams,
serialization: global("account-auth"),
@@ -2318,6 +2324,24 @@ mod tests {
Ok(())
}
#[test]
fn serialize_get_account_token_usage() -> Result<()> {
let request = ClientRequest::GetAccountTokenUsage {
request_id: RequestId::Integer(1),
params: None,
};
assert_eq!(request.id(), &RequestId::Integer(1));
assert_eq!(request.method(), "account/tokenUsage/read");
assert_eq!(
json!({
"method": "account/tokenUsage/read",
"id": 1,
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_client_response() -> Result<()> {
let cwd = absolute_path("/tmp");

View File

@@ -185,6 +185,33 @@ pub struct GetAccountRateLimitsResponse {
pub rate_limits_by_limit_id: Option<HashMap<String, RateLimitSnapshot>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountTokenUsageResponse {
pub summary: AccountTokenUsageSummary,
pub daily_usage_buckets: Option<Vec<AccountTokenUsageDailyBucket>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountTokenUsageSummary {
pub lifetime_tokens: Option<i64>,
pub peak_daily_tokens: Option<i64>,
pub longest_running_turn_sec: Option<i64>,
pub current_streak_days: Option<i64>,
pub longest_streak_days: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountTokenUsageDailyBucket {
pub start_date: String,
pub tokens: i64,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]

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? }`.

View File

@@ -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)

View File

@@ -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;

View File

@@ -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,
}]),
}
);
}
}

View File

@@ -3,6 +3,7 @@ use crate::types::ConfigFileResponse;
use crate::types::PaginatedListTaskListItem;
use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind;
use crate::types::RateLimitStatusPayload;
use crate::types::TokenUsageProfile;
use crate::types::TurnAttemptsSiblingTurnsResponse;
use anyhow::Result;
use codex_api::SharedAuthProvider;
@@ -301,6 +302,20 @@ impl Client {
Ok(Self::rate_limit_snapshots_from_payload(payload))
}
pub async fn get_token_usage_profile(&self) -> Result<TokenUsageProfile> {
let url = self.token_usage_profile_url();
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
self.decode_json(&url, &ct, &body)
}
fn token_usage_profile_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/profiles/me", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/profiles/me", self.base_url),
}
}
pub async fn send_add_credits_nudge_email(
&self,
credit_type: AddCreditsNudgeCreditType,
@@ -862,4 +877,35 @@ mod tests {
serde_json::json!({ "credit_type": "usage_limit" })
);
}
#[test]
fn token_usage_profile_uses_expected_paths() {
let codex_client = Client {
base_url: "https://example.test".to_string(),
http: reqwest::Client::new(),
auth_provider: codex_model_provider::unauthenticated_auth_provider(),
user_agent: None,
chatgpt_account_id: None,
chatgpt_account_is_fedramp: false,
path_style: PathStyle::CodexApi,
};
assert_eq!(
codex_client.token_usage_profile_url(),
"https://example.test/api/codex/profiles/me"
);
let chatgpt_client = Client {
base_url: "https://chatgpt.com/backend-api".to_string(),
http: reqwest::Client::new(),
auth_provider: codex_model_provider::unauthenticated_auth_provider(),
user_agent: None,
chatgpt_account_id: None,
chatgpt_account_is_fedramp: false,
path_style: PathStyle::ChatGptApi,
};
assert_eq!(
chatgpt_client.token_usage_profile_url(),
"https://chatgpt.com/backend-api/wham/profiles/me"
);
}
}

View File

@@ -9,4 +9,7 @@ pub use types::CodeTaskDetailsResponseExt;
pub use types::ConfigFileResponse;
pub use types::PaginatedListTaskListItem;
pub use types::TaskListItem;
pub use types::TokenUsageProfile;
pub use types::TokenUsageProfileDailyBucket;
pub use types::TokenUsageProfileStats;
pub use types::TurnAttemptsSiblingTurnsResponse;

View File

@@ -318,6 +318,27 @@ pub struct TurnAttemptsSiblingTurnsResponse {
pub sibling_turns: Vec<HashMap<String, Value>>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct TokenUsageProfile {
pub stats: TokenUsageProfileStats,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct TokenUsageProfileStats {
pub lifetime_tokens: Option<i64>,
pub peak_daily_tokens: Option<i64>,
pub longest_running_turn_sec: Option<i64>,
pub current_streak_days: Option<i64>,
pub longest_streak_days: Option<i64>,
pub daily_usage_buckets: Option<Vec<TokenUsageProfileDailyBucket>>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct TokenUsageProfileDailyBucket {
pub start_date: String,
pub tokens: i64,
}
#[cfg(test)]
mod tests {
use super::*;