Skip to content

Commit 5e27b70

Browse files
authored
Enhancing /usage to support credits information (#3333)
* Extend /usage to support credits with sub commands to show only credits or context
1 parent ad24ef1 commit 5e27b70

File tree

6 files changed

+371
-2
lines changed

6 files changed

+371
-2
lines changed

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;
@@ -67,6 +68,9 @@ pub enum ApiClientError {
6768
#[error("{}", SdkErrorDisplay(.0))]
6869
CreateSubscriptionToken(#[from] SdkError<CreateSubscriptionTokenError, HttpResponse>),
6970

71+
#[error("{}", SdkErrorDisplay(.0))]
72+
GetUsageLimitsError(#[from] SdkError<GetUsageLimitsError, HttpResponse>),
73+
7074
/// Returned from the backend when the user input is too large to fit within the model context
7175
/// window.
7276
///
@@ -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
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
use amzn_codewhisperer_client::types::AccessDeniedExceptionReason;
2+
use amzn_codewhisperer_client::operation::get_usage_limits::GetUsageLimitsError;
3+
4+
use crate::api_client::ApiClientError;
5+
6+
#[derive(Debug, PartialEq)]
7+
pub enum GetUsageLimitsErrorType {
8+
FeatureNotSupported,
9+
Other,
10+
}
11+
12+
/// Classify GetUsageLimits API errors
13+
pub fn classify_get_usage_limits_error(api_error: &ApiClientError) -> GetUsageLimitsErrorType {
14+
match api_error {
15+
ApiClientError::GetUsageLimitsError(sdk_err) => {
16+
match sdk_err.as_service_error() {
17+
Some(GetUsageLimitsError::AccessDeniedError(access_denied)) => {
18+
match access_denied.reason() {
19+
Some(AccessDeniedExceptionReason::FeatureNotSupported) => {
20+
GetUsageLimitsErrorType::FeatureNotSupported
21+
},
22+
_ => GetUsageLimitsErrorType::Other,
23+
}
24+
},
25+
_ => GetUsageLimitsErrorType::Other,
26+
}
27+
},
28+
_ => GetUsageLimitsErrorType::Other,
29+
}
30+
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod customization;
33
mod delay_interceptor;
44
mod endpoints;
55
mod error;
6+
pub mod error_utils;
67
pub mod model;
78
mod opt_out;
89
pub mod profile;
@@ -375,6 +376,15 @@ impl ApiClient {
375376
.map_err(ApiClientError::CreateSubscriptionToken)
376377
}
377378

379+
pub async fn get_usage_limits(&self) -> Result<amzn_codewhisperer_client::operation::get_usage_limits::GetUsageLimitsOutput, ApiClientError> {
380+
self.client
381+
.get_usage_limits()
382+
.set_origin(Some(amzn_codewhisperer_client::types::Origin::from("KIRO_CLI")))
383+
.send()
384+
.await
385+
.map_err(ApiClientError::GetUsageLimitsError)
386+
}
387+
378388
pub async fn send_message(&self, conversation: ConversationState) -> Result<SendMessageOutput, ApiClientError> {
379389
debug!("Sending conversation: {:#?}", conversation);
380390

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

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
use clap::Args;
2+
use crossterm::style::Color;
3+
use crossterm::{execute, style};
24

35
use crate::cli::chat::token_counter::TokenCount;
46
use crate::cli::chat::{ChatError, ChatSession, ChatState};
@@ -19,19 +21,111 @@ pub struct DetailedUsageData {
1921
pub dropped_context_files: Vec<(String, String)>,
2022
}
2123

24+
/// Billing usage data from API
25+
#[derive(Debug)]
26+
pub struct BillingUsageData {
27+
pub status: BillingDataStatus,
28+
pub plan_name: String,
29+
pub overages_enabled: bool,
30+
pub billing_cycle_reset: String,
31+
pub usage_breakdowns: Vec<UsageBreakdownInfo>,
32+
pub bonus_credits: Vec<BonusCredit>,
33+
}
34+
35+
#[derive(Debug)]
36+
pub enum BillingDataStatus {
37+
Available,
38+
FeatureNotSupported,
39+
BackendError(String),
40+
}
41+
42+
/// Individual usage breakdown information
43+
#[derive(Debug)]
44+
pub struct UsageBreakdownInfo {
45+
#[allow(dead_code)]
46+
pub resource_type: String,
47+
pub display_name: String,
48+
pub used: f64,
49+
pub limit: f64,
50+
pub percentage: i32,
51+
}
52+
53+
/// Individual bonus credit information
54+
#[derive(Debug)]
55+
pub struct BonusCredit {
56+
pub name: String,
57+
pub used: f64,
58+
pub total: f64,
59+
pub days_until_expiry: i64,
60+
}
61+
2262
/// Arguments for the usage command that displays token usage statistics and context window
2363
/// information.
2464
///
2565
/// This command shows how many tokens are being used by different components (context files, tools,
2666
/// assistant responses, and user prompts) within the current chat session's context window.
2767
#[deny(missing_docs)]
2868
#[derive(Debug, PartialEq, Args)]
29-
pub struct UsageArgs;
69+
pub struct UsageArgs {
70+
/// Show only context window usage
71+
#[arg(long)]
72+
context: bool,
73+
/// Show only credits and billing information
74+
#[arg(long)]
75+
credits: bool,
76+
}
3077

3178
impl UsageArgs {
3279
pub async fn execute(self, os: &Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
80+
match (self.context, self.credits) {
81+
(true, false) => {
82+
// Show only context window usage
83+
self.show_context_usage(os, session).await
84+
},
85+
(false, true) => {
86+
// Show only credits/billing information
87+
self.show_credits_info(os, session).await
88+
},
89+
(false, false) => {
90+
// Show both (default behavior)
91+
self.show_full_usage(os, session).await
92+
},
93+
(true, true) => {
94+
// Both flags specified - show error
95+
execute!(
96+
session.stderr,
97+
style::SetForegroundColor(Color::Red),
98+
style::Print("Error: Cannot specify both --context and --credits flags\n"),
99+
style::SetForegroundColor(Color::Reset),
100+
)?;
101+
Ok(ChatState::PromptUser { skip_printing_tools: true })
102+
}
103+
}
104+
}
105+
106+
async fn show_context_usage(&self, os: &Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
107+
let usage_data = usage_data_provider::get_detailed_usage_data(session, os).await?;
108+
usage_renderer::render_context_window(&usage_data, session).await?;
109+
Ok(ChatState::PromptUser { skip_printing_tools: true })
110+
}
111+
112+
async fn show_credits_info(&self, os: &Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
113+
let billing_data = usage_data_provider::get_billing_usage_data(os).await?;
114+
usage_renderer::render_billing_info(&billing_data, session, true).await?;
115+
Ok(ChatState::PromptUser { skip_printing_tools: true })
116+
}
117+
118+
async fn show_full_usage(&self, os: &Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
119+
// Get both billing and context data
120+
let billing_data = usage_data_provider::get_billing_usage_data(os).await?;
33121
let usage_data = usage_data_provider::get_detailed_usage_data(session, os).await?;
122+
123+
// Render billing information
124+
usage_renderer::render_billing_info(&billing_data, session, false).await?;
125+
126+
// Render context window information
34127
usage_renderer::render_context_window(&usage_data, session).await?;
128+
35129
Ok(ChatState::PromptUser { skip_printing_tools: true })
36130
}
37131
}

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

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
use chrono::{DateTime, Utc};
2+
3+
use crate::api_client::error_utils::{classify_get_usage_limits_error, GetUsageLimitsErrorType};
14
use crate::cli::chat::cli::model::context_window_tokens;
25
use crate::cli::chat::token_counter::{CharCount, TokenCount};
36
use crate::cli::chat::{ChatError, ChatSession};
@@ -34,6 +37,118 @@ pub(super) async fn get_detailed_usage_data(session: &mut ChatSession, os: &Os)
3437
})
3538
}
3639

40+
/// Get billing usage data from API
41+
pub(super) async fn get_billing_usage_data(os: &Os) -> Result<super::BillingUsageData, ChatError> {
42+
match os.client.get_usage_limits().await {
43+
Ok(usage_limits) => {
44+
let usage_breakdown = usage_limits.usage_breakdown_list();
45+
46+
// Get plan info
47+
let plan_name = usage_limits.subscription_info()
48+
.map_or("Unknown", |si| si.subscription_title())
49+
.to_string();
50+
51+
// Get overage status
52+
let overages_enabled = usage_limits.overage_configuration()
53+
.is_some_and(|config| config.overage_status().as_str() == "ENABLED");
54+
55+
// Get billing cycle reset date from main object
56+
let billing_cycle_reset = usage_limits.next_date_reset()
57+
.map_or_else(
58+
|| "Billing cycle reset: Unknown".to_string(),
59+
|next_reset| {
60+
let reset_secs = next_reset.secs();
61+
let reset_date = DateTime::from_timestamp(reset_secs, 0).unwrap_or_else(Utc::now);
62+
format!("Billing cycle reset: {}", reset_date.format("%m/%d"))
63+
}
64+
);
65+
66+
// Process all usage breakdowns
67+
let mut usage_breakdowns = Vec::new();
68+
let mut bonus_credits = Vec::new();
69+
70+
for item in usage_breakdown {
71+
// Skip items without free trial info
72+
if item.free_trial_info().is_none() {
73+
continue;
74+
}
75+
76+
let resource_type = item.resource_type()
77+
.map_or("Unknown", |rt| rt.as_str())
78+
.to_string();
79+
let display_name = item.display_name_plural()
80+
.or_else(|| item.display_name())
81+
.unwrap_or(&resource_type)
82+
.to_string();
83+
let used = item.current_usage_with_precision().unwrap_or(0.0);
84+
let limit = item.usage_limit_with_precision().unwrap_or(0.0);
85+
let percentage = if limit > 0.0 { (used / limit * 100.0) as i32 } else { 0 };
86+
87+
usage_breakdowns.push(super::UsageBreakdownInfo {
88+
resource_type: resource_type.clone(),
89+
display_name: display_name.clone(),
90+
used,
91+
limit,
92+
percentage,
93+
});
94+
95+
// Check for bonus credits in this item
96+
if let Some(free_trial_info) = item.free_trial_info() {
97+
if free_trial_info.free_trial_status().map(|s| s.as_str()) == Some("ACTIVE") {
98+
let bonus_used = free_trial_info.current_usage_with_precision().unwrap_or(0.0);
99+
let bonus_total = free_trial_info.usage_limit_with_precision().unwrap_or(0.0);
100+
101+
if let Some(expiry_timestamp) = free_trial_info.free_trial_expiry() {
102+
let expiry_secs = expiry_timestamp.secs();
103+
let expiry_date = DateTime::from_timestamp(expiry_secs, 0).unwrap_or_else(Utc::now);
104+
let now = Utc::now();
105+
let days_until_expiry = (expiry_date - now).num_days().max(0);
106+
107+
bonus_credits.push(super::BonusCredit {
108+
name: display_name,
109+
used: bonus_used,
110+
total: bonus_total,
111+
days_until_expiry,
112+
});
113+
}
114+
}
115+
}
116+
}
117+
118+
Ok(super::BillingUsageData {
119+
status: super::BillingDataStatus::Available,
120+
plan_name,
121+
overages_enabled,
122+
billing_cycle_reset,
123+
usage_breakdowns,
124+
bonus_credits,
125+
})
126+
},
127+
Err(err) => {
128+
// Check if this is an AccessDeniedError with FEATURE_NOT_SUPPORTED reason
129+
let is_feature_not_supported = matches!(
130+
classify_get_usage_limits_error(&err),
131+
GetUsageLimitsErrorType::FeatureNotSupported
132+
);
133+
134+
let status = if is_feature_not_supported {
135+
super::BillingDataStatus::FeatureNotSupported
136+
} else {
137+
super::BillingDataStatus::BackendError(err.to_string())
138+
};
139+
140+
Ok(super::BillingUsageData {
141+
status,
142+
plan_name: "Unknown".to_string(),
143+
overages_enabled: false,
144+
billing_cycle_reset: "Unknown".to_string(),
145+
usage_breakdowns: Vec::new(),
146+
bonus_credits: Vec::new(),
147+
})
148+
},
149+
}
150+
}
151+
37152
/// Get total usage percentage (external API)
38153
pub async fn get_total_usage_percentage(session: &mut ChatSession, os: &Os) -> Result<f32, ChatError> {
39154
let data = get_detailed_usage_data(session, os).await?;

0 commit comments

Comments
 (0)