Skip to content

Commit 2755d1b

Browse files
committed
Expand /usage to show billing information. Introduce context/credits subcommands for targetted information
1 parent 2bda8e4 commit 2755d1b

File tree

5 files changed

+222
-16
lines changed

5 files changed

+222
-16
lines changed

.cargo/config.toml

Lines changed: 0 additions & 10 deletions
This file was deleted.

crates/chat-cli/src/api_client/error.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use amzn_codewhisperer_client::operation::create_subscription_token::CreateSubscriptionTokenError;
22
use amzn_codewhisperer_client::operation::generate_completions::GenerateCompletionsError;
33
use amzn_codewhisperer_client::operation::get_profile::GetProfileError;
4+
use amzn_codewhisperer_client::operation::get_usage_limits::GetUsageLimitsError;
45
use amzn_codewhisperer_client::operation::list_available_customizations::ListAvailableCustomizationsError;
56
use amzn_codewhisperer_client::operation::list_available_models::ListAvailableModelsError;
67
use amzn_codewhisperer_client::operation::list_available_profiles::ListAvailableProfilesError;
@@ -104,6 +105,9 @@ pub enum ApiClientError {
104105

105106
#[error(transparent)]
106107
GetProfileError(#[from] SdkError<GetProfileError, HttpResponse>),
108+
109+
#[error(transparent)]
110+
GetUsageLimitsError(#[from] SdkError<GetUsageLimitsError, HttpResponse>),
107111
}
108112

109113
impl ApiClientError {
@@ -130,6 +134,7 @@ impl ApiClientError {
130134
Self::ListAvailableModelsError(e) => sdk_status_code(e),
131135
Self::DefaultModelNotFound => None,
132136
Self::GetProfileError(e) => sdk_status_code(e),
137+
Self::GetUsageLimitsError(e) => sdk_status_code(e),
133138
}
134139
}
135140
}
@@ -158,6 +163,7 @@ impl ReasonCode for ApiClientError {
158163
Self::ListAvailableModelsError(e) => sdk_error_code(e),
159164
Self::DefaultModelNotFound => "DefaultModelNotFound".to_string(),
160165
Self::GetProfileError(e) => sdk_error_code(e),
166+
Self::GetUsageLimitsError(e) => sdk_error_code(e),
161167
}
162168
}
163169
}

crates/chat-cli/src/api_client/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,15 @@ impl ApiClient {
359359
Ok(mcp_enabled)
360360
}
361361

362+
pub async fn get_usage_limits(&self) -> Result<amzn_codewhisperer_client::operation::get_usage_limits::GetUsageLimitsOutput, ApiClientError> {
363+
let request = self
364+
.client
365+
.get_usage_limits();
366+
367+
let response = request.send().await?;
368+
Ok(response)
369+
}
370+
362371
pub async fn create_subscription_token(&self) -> Result<CreateSubscriptionTokenOutput, ApiClientError> {
363372
if cfg!(test) {
364373
return Ok(CreateSubscriptionTokenOutput::builder()

crates/chat-cli/src/cli/chat/cli/usage.rs

Lines changed: 206 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
use clap::Args;
2-
use crossterm::style::Attribute;
2+
use crossterm::style::{Attribute, Color};
33
use crossterm::{
44
execute,
55
queue,
66
style,
77
};
8+
use chrono::{DateTime, Utc};
89

910
use super::model::context_window_tokens;
1011
use crate::cli::chat::token_counter::{
@@ -73,19 +74,189 @@ pub async fn get_total_usage_percentage(session: &mut ChatSession, os: &Os) -> R
7374
Ok(calculate_usage_percentage(data.total_tokens, data.context_window_size))
7475
}
7576

77+
/// Display billing and subscription information
78+
async fn display_billing_info(os: &Os, session: &mut ChatSession) -> Result<bool, ChatError> {
79+
match os.client.get_usage_limits().await {
80+
Ok(usage_limits) => {
81+
display_user_and_plan_info(&usage_limits, session).await?;
82+
display_bonus_credits(&usage_limits, session).await?;
83+
display_estimated_usage(&usage_limits, session).await?;
84+
Ok(true)
85+
},
86+
Err(_) => {
87+
// Hide billing section when not authenticated
88+
Ok(false)
89+
}
90+
}
91+
}
92+
93+
async fn display_user_and_plan_info(_usage_limits: &amzn_codewhisperer_client::operation::get_usage_limits::GetUsageLimitsOutput, session: &mut ChatSession) -> Result<(), ChatError> {
94+
execute!(
95+
session.stderr,
96+
style::SetAttribute(style::Attribute::Bold),
97+
style::Print("Usage details\n"),
98+
style::SetAttribute(style::Attribute::Reset),
99+
style::Print("To manage your account, upgrade your plan or configure overages use "),
100+
style::SetForegroundColor(Color::Blue),
101+
style::Print("/usage manage"),
102+
style::SetForegroundColor(Color::Reset),
103+
style::Print(" to open admin hub\n\n"),
104+
)?;
105+
Ok(())
106+
}
107+
108+
async fn display_bonus_credits(usage_limits: &amzn_codewhisperer_client::operation::get_usage_limits::GetUsageLimitsOutput, session: &mut ChatSession) -> Result<(), ChatError> {
109+
let usage_breakdown = usage_limits.usage_breakdown_list();
110+
111+
// Find Credits resource type for bonus credits
112+
if let Some(credits) = usage_breakdown.iter().find(|item| {
113+
item.resource_type().map_or(false, |rt| rt.as_str() == "CREDIT")
114+
}) {
115+
if let Some(free_trial_info) = credits.free_trial_info() {
116+
let used = free_trial_info.current_usage().unwrap_or(0);
117+
let total = free_trial_info.usage_limit().unwrap_or(0);
118+
119+
// Calculate days until expiry
120+
if let Some(expiry_timestamp) = free_trial_info.free_trial_expiry() {
121+
let expiry_secs = expiry_timestamp.secs();
122+
let expiry_date = DateTime::from_timestamp(expiry_secs, 0).unwrap_or_else(|| Utc::now());
123+
let now = Utc::now();
124+
let days_until_expiry = (expiry_date - now).num_days().max(0);
125+
126+
execute!(
127+
session.stderr,
128+
style::SetForegroundColor(Color::Red),
129+
style::Print("🎁 "),
130+
style::SetForegroundColor(Color::Reset),
131+
style::SetAttribute(style::Attribute::Bold),
132+
style::Print("Bonus credits: "),
133+
style::SetAttribute(style::Attribute::Reset),
134+
style::Print("You have bonus credits applied to your account, we will use these first, then your plan credits.\n"),
135+
style::Print(format!("New user credit bonus: {}/{} credits used, expires in {} days\n\n", used, total, days_until_expiry)),
136+
)?;
137+
}
138+
}
139+
}
140+
Ok(())
141+
}
142+
143+
async fn display_estimated_usage(usage_limits: &amzn_codewhisperer_client::operation::get_usage_limits::GetUsageLimitsOutput, session: &mut ChatSession) -> Result<(), ChatError> {
144+
let usage_breakdown = usage_limits.usage_breakdown_list();
145+
146+
// Get plan info
147+
let plan_name = usage_limits.subscription_info()
148+
.map(|si| si.subscription_title())
149+
.unwrap_or("Unknown");
150+
151+
// Get days until reset
152+
let days_left = usage_limits.days_until_reset().unwrap_or(0);
153+
154+
// Get credits info
155+
if let Some(credits) = usage_breakdown.iter().find(|item| {
156+
item.resource_type().map_or(false, |rt| rt.as_str() == "CREDIT")
157+
}) {
158+
let used = credits.current_usage();
159+
let limit = credits.usage_limit();
160+
let percentage = if limit > 0 { (used as f32 / limit as f32 * 100.0) as i32 } else { 0 };
161+
162+
execute!(
163+
session.stderr,
164+
style::Print(format!("Current plan: {}\n", plan_name)),
165+
style::Print("Overages: Off\n"),
166+
style::Print(format!("Days left in billing cycle: {}\n\n", days_left)),
167+
style::Print(format!("Current plan credit usage ({} of {} credits used)\n", used, limit)),
168+
)?;
169+
170+
// Draw progress bar
171+
let bar_width = 60;
172+
let filled_width = (percentage as f32 / 100.0 * bar_width as f32) as usize;
173+
let empty_width = bar_width - filled_width;
174+
175+
execute!(
176+
session.stderr,
177+
style::SetForegroundColor(Color::Magenta),
178+
style::Print("█".repeat(filled_width)),
179+
style::SetForegroundColor(Color::DarkGrey),
180+
style::Print("█".repeat(empty_width)),
181+
style::SetForegroundColor(Color::Reset),
182+
style::Print(format!(" {}%\n\n", percentage)),
183+
)?;
184+
}
185+
186+
Ok(())
187+
}
188+
76189
/// Arguments for the usage command that displays token usage statistics and context window
77190
/// information.
78191
///
79192
/// This command shows how many tokens are being used by different components (context files, tools,
80193
/// assistant responses, and user prompts) within the current chat session's context window.
81194
#[deny(missing_docs)]
82195
#[derive(Debug, PartialEq, Args)]
83-
pub struct UsageArgs;
196+
pub struct UsageArgs {
197+
/// Show only context window usage
198+
#[arg(long)]
199+
context: bool,
200+
/// Show only credits and billing information
201+
#[arg(long)]
202+
credits: bool,
203+
}
84204

85205
impl UsageArgs {
86206
pub async fn execute(self, os: &Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
207+
match (self.context, self.credits) {
208+
(true, false) => {
209+
// Show only context window usage
210+
self.show_context_usage(os, session).await
211+
},
212+
(false, true) => {
213+
// Show only credits/billing information
214+
self.show_credits_info(os, session).await
215+
},
216+
(false, false) => {
217+
// Show both (default behavior)
218+
self.show_full_usage(os, session).await
219+
},
220+
(true, true) => {
221+
// Both flags specified - show error
222+
execute!(
223+
session.stderr,
224+
style::SetForegroundColor(Color::Red),
225+
style::Print("Error: Cannot specify both --context and --credits flags\n"),
226+
style::SetForegroundColor(Color::Reset),
227+
)?;
228+
Ok(ChatState::PromptUser { skip_printing_tools: true })
229+
}
230+
}
231+
}
232+
233+
async fn show_context_usage(&self, os: &Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
87234
let usage_data = get_detailed_usage_data(session, os).await?;
235+
self.display_context_window(&usage_data, session).await?;
236+
Ok(ChatState::PromptUser { skip_printing_tools: true })
237+
}
238+
239+
async fn show_credits_info(&self, os: &Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
240+
let billing_displayed = display_billing_info(os, session).await?;
241+
if !billing_displayed {
242+
execute!(
243+
session.stderr,
244+
style::Print("Credit based usage is not supported for your subscription\n"),
245+
)?;
246+
}
247+
Ok(ChatState::PromptUser { skip_printing_tools: true })
248+
}
249+
250+
async fn show_full_usage(&self, os: &Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
251+
// Try to display billing information first (silently ignore if not available)
252+
let _billing_displayed = display_billing_info(os, session).await?;
88253

254+
let usage_data = get_detailed_usage_data(session, os).await?;
255+
self.display_context_window(&usage_data, session).await?;
256+
Ok(ChatState::PromptUser { skip_printing_tools: true })
257+
}
258+
259+
async fn display_context_window(&self, usage_data: &DetailedUsageData, session: &mut ChatSession) -> Result<(), ChatError> {
89260
if !usage_data.dropped_context_files.is_empty() {
90261
execute!(
91262
session.stderr,
@@ -252,8 +423,38 @@ impl UsageArgs {
252423
StyledText::reset(),
253424
)?;
254425

255-
Ok(ChatState::PromptUser {
256-
skip_printing_tools: true,
257-
})
426+
Ok(())
427+
}
428+
}
429+
430+
#[cfg(test)]
431+
mod tests {
432+
use super::*;
433+
434+
#[test]
435+
fn test_calculate_usage_percentage() {
436+
let char_count: CharCount = 4000.into(); // 4000 chars ≈ 1000 tokens
437+
let tokens: TokenCount = char_count.into();
438+
let context_window_size = 10000;
439+
let percentage = calculate_usage_percentage(tokens, context_window_size);
440+
assert_eq!(percentage, 10.0);
441+
}
442+
443+
#[test]
444+
fn test_calculate_usage_percentage_zero() {
445+
let char_count: CharCount = 0.into();
446+
let tokens: TokenCount = char_count.into();
447+
let context_window_size = 10000;
448+
let percentage = calculate_usage_percentage(tokens, context_window_size);
449+
assert_eq!(percentage, 0.0);
450+
}
451+
452+
#[test]
453+
fn test_calculate_usage_percentage_full() {
454+
let char_count: CharCount = 40000.into(); // 40000 chars ≈ 10000 tokens
455+
let tokens: TokenCount = char_count.into();
456+
let context_window_size = 10000;
457+
let percentage = calculate_usage_percentage(tokens, context_window_size);
458+
assert_eq!(percentage, 100.0);
258459
}
259460
}

rust-toolchain.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[toolchain]
2-
channel = "1.88.0"
2+
channel = "1.90.0"
33
profile = "minimal"
44
components = ["rustfmt", "clippy"]
55
targets = [

0 commit comments

Comments
 (0)