Show estimated thread usage in /status (#38281)

## What changed

- Extend `account/usage/read` with an optional `threadId` request and a
  backward-compatible `threadUsage` response containing estimated credits,
  optional USD cost, and model, reasoning, speed, and token breakdowns.
- Fetch thread usage for eligible Business and Enterprise plans when `/status`
  is opened, then update the rendered status card asynchronously.
- Preserve terminal scrollback when replacing a visible status-card tail, and
  append a refreshed card when the original tail can no longer be updated.

## Testing

- Add app-server coverage for authentication, canonical thread IDs,
  unavailable billing routes, and malformed IDs.
- Add TUI coverage for usage formatting, stale responses, retries, history
  updates, terminal reflow, and scrollback preservation.

GitOrigin-RevId: 49300914b3fba917c0f08e8595363e04e5ba6d37
This commit is contained in:
Felipe Coury
2026-08-13 00:30:08 +00:00
committed by copyberry
parent 27a98dde4d
commit f1a1fce26a
55 changed files with 2903 additions and 60 deletions
@@ -471,6 +471,19 @@ macro_rules! client_response_payload_from_impl {
($variant:ident, $response:ty, manual) => {};
}
/// Preserve explicit `undefined` accepted by the original stable usage request.
///
/// A Rust-based TypeScript proxy retains dependency discovery; a raw `#[ts(type = ...)]`
/// override would silently omit the generated params import and schema fixture.
#[allow(dead_code)]
#[derive(TS)]
#[ts(untagged)]
enum GetAccountTokenUsageParamsTypeScript {
Params(v2::GetAccountTokenUsageParams),
#[ts(type = "undefined")]
Undefined,
}
client_request_definitions! {
Initialize => "initialize" {
params: v1::InitializeParams,
@@ -1095,7 +1108,7 @@ client_request_definitions! {
},
GetAccountTokenUsage => "account/usage/read" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
params: #[ts(optional, as = "Option<GetAccountTokenUsageParamsTypeScript>", inline)] #[serde(default, skip_serializing_if = "Option::is_none")] v2::NullableGetAccountTokenUsageParams,
serialization: None,
response: v2::GetAccountTokenUsageResponse,
},
@@ -2850,6 +2863,56 @@ mod tests {
Ok(())
}
#[test]
fn serialize_get_account_thread_usage() -> Result<()> {
let request = ClientRequest::GetAccountTokenUsage {
request_id: RequestId::Integer(1),
params: Some(v2::GetAccountTokenUsageParams {
thread_id: Some("thread-123".to_string()),
}),
};
assert_eq!(
json!({
"method": "account/usage/read",
"id": 1,
"params": { "threadId": "thread-123" },
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn deserialize_legacy_get_account_token_usage_response() -> Result<()> {
let response: v2::GetAccountTokenUsageResponse = serde_json::from_value(json!({
"summary": {
"lifetimeTokens": null,
"peakDailyTokens": null,
"longestRunningTurnSec": null,
"currentStreakDays": null,
"longestStreakDays": null,
},
"dailyUsageBuckets": null,
}))?;
assert_eq!(
response,
v2::GetAccountTokenUsageResponse {
summary: v2::AccountTokenUsageSummary {
lifetime_tokens: None,
peak_daily_tokens: None,
longest_running_turn_sec: None,
current_streak_days: None,
longest_streak_days: None,
},
daily_usage_buckets: None,
thread_usage: None,
},
);
assert_eq!(serde_json::to_value(response)?["threadUsage"], json!(null));
Ok(())
}
#[test]
fn serialize_get_workspace_messages() -> Result<()> {
let request = ClientRequest::GetWorkspaceMessages {
@@ -1,3 +1,4 @@
use super::ThreadUsage;
use crate::JsonSchema;
use crate::TS;
use crate::protocol::common::AuthMode;
@@ -383,12 +384,27 @@ pub enum ConsumeAccountRateLimitResetCreditOutcome {
AlreadyRedeemed,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountTokenUsageParams {
/// When present, read estimated usage for this thread instead of account-wide token activity.
#[ts(optional = nullable)]
pub thread_id: Option<String>,
}
pub type NullableGetAccountTokenUsageParams = Option<GetAccountTokenUsageParams>;
#[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>>,
/// Estimated usage when a thread was requested and its billing route is available.
#[serde(default)]
#[ts(optional, as = "Option<Option<ThreadUsage>>")]
pub thread_usage: Option<ThreadUsage>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -26,6 +26,7 @@ mod remote_control;
mod review;
mod thread;
mod thread_data;
mod thread_usage;
mod turn;
mod windows_sandbox;
@@ -56,6 +57,7 @@ pub use review::*;
pub use shared::*;
pub use thread::*;
pub use thread_data::*;
pub use thread_usage::*;
pub use turn::*;
pub use windows_sandbox::*;
@@ -0,0 +1,29 @@
use crate::JsonSchema;
use crate::TS;
use serde::Deserialize;
use serde::Serialize;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadUsage {
pub thread_id: String,
pub estimated_usage_credits_micros: i64,
pub estimated_usage_usd_micros: Option<i64>,
pub groups: Vec<ThreadUsageBreakdownGroup>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadUsageBreakdownGroup {
pub model: Option<String>,
pub reasoning_effort: Option<String>,
pub speed: Option<String>,
pub estimated_usage_credits_micros: i64,
pub net_new_input_tokens: Option<i64>,
pub cached_input_tokens: Option<i64>,
pub input_tokens: Option<i64>,
pub output_tokens: Option<i64>,
pub total_tokens: Option<i64>,
}