Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 134 additions & 1 deletion src-tauri/src/cli/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use clap::Subcommand;
use serde::Serialize;
use std::time::{Duration, Instant};

use crate::cli::ui::{create_table, info, success, to_json};
use crate::cli::ui::{create_table, highlight, info, success, to_json, warning};
use crate::error::AppError;
use crate::services::{AuthService, ManagedAuthAccount, ManagedAuthDeviceCodeResponse};

Expand Down Expand Up @@ -47,6 +47,21 @@ pub enum AuthCommand {
#[arg(long)]
yes: bool,
},
/// Reset Codex rate limit quota using a banked reset credit
ResetQuota {
/// Account ID (defaults to active account)
#[arg(long)]
account_id: Option<String>,
/// Specific credit ID to consume (defaults to earliest expiring)
#[arg(long)]
credit_id: Option<String>,
/// Confirm consumption (safety gate; without this, runs in dry-run mode)
#[arg(long)]
confirm: bool,
/// Print machine-readable JSON
#[arg(long)]
json: bool,
},
}

#[derive(Serialize)]
Expand All @@ -66,6 +81,12 @@ pub fn execute(cmd: AuthCommand) -> Result<(), AppError> {
AuthCommand::Default { account_id } => set_default(&runtime, &account_id),
AuthCommand::Remove { account_id, yes } => remove_account(&runtime, &account_id, yes),
AuthCommand::Logout { yes } => logout(&runtime, yes),
AuthCommand::ResetQuota {
account_id,
credit_id,
confirm,
json,
} => reset_quota(&runtime, account_id, credit_id, confirm, json),
}
}

Expand Down Expand Up @@ -325,3 +346,115 @@ mod tests {
assert_eq!(normalize_account_id(" acc-123 ").unwrap(), "acc-123");
}
}

fn reset_quota(
runtime: &tokio::runtime::Runtime,
account_id: Option<String>,
credit_id: Option<String>,
confirm: bool,
json: bool,
) -> Result<(), AppError> {
let quota = runtime
.block_on(AuthService::get_quota(
AUTH_PROVIDER_CODEX_OAUTH,
account_id.as_deref(),
))
.map_err(AppError::Message)?;

let summary = quota.reset_credits.as_ref();
let available_count = summary.map(|s| s.available_count).unwrap_or(0);
if available_count == 0 {
return Err(AppError::Message(
"No rate limit reset credits are available for this account.".to_string(),
));
}

let credits = summary.map(|s| &s.credits[..]).unwrap_or(&[]);
let selected_credit = match &credit_id {
Some(cid) => credits.iter().find(|c| &c.id == cid).cloned().ok_or_else(|| {
AppError::Message(format!(
"Specified credit ID '{cid}' was not found in available credits."
))
})?,
None => credits.first().cloned().ok_or_else(|| {
AppError::Message("No available reset credit to consume.".to_string())
})?,
};

if !confirm {
if json {
let dry_run = serde_json::json!({
"dryRun": true,
"targetCredit": selected_credit,
"availableCount": available_count,
"message": "To redeem this credit and reset rate limits, run again with --confirm.",
});
println!(
"{}",
to_json(&dry_run).map_err(|source| AppError::JsonSerialize { source })?
);
return Ok(());
}

println!("{}", highlight("=== Rate Limit Reset Credit (Dry Run) ==="));
println!("Credit ID: {}", selected_credit.id);
if let Some(title) = &selected_credit.title {
println!("Title: {}", title);
}
if let Some(expires) = &selected_credit.expires_at {
println!("Expires at: {}", expires);
}
println!("Available Cards: {}", available_count);
println!();
println!(
"{}",
warning("⚠ Consuming a reset credit is permanent and cannot be undone.")
);
println!(
"{}",
info(
"To consume this credit, re-run with --confirm:
cc-switch auth reset-quota --confirm"
)
);
return Ok(());
}

let result = runtime
.block_on(AuthService::reset_quota(
AUTH_PROVIDER_CODEX_OAUTH,
account_id.as_deref(),
Some(&selected_credit.id),
))
.map_err(AppError::Message)?;

if json {
println!(
"{}",
to_json(&result).map_err(|source| AppError::JsonSerialize { source })?
);
return Ok(());
}

println!(
"{}",
success("✓ Rate limit reset credit successfully redeemed!")
);
println!("Credit ID: {}", result.credit_id);
println!("Redeem Request ID: {}", result.redeem_request_id);
println!("Upstream Code: {}", result.code);
if let Some(win) = result.windows_reset {
println!("Windows Reset: {}", win);
}
if let Some(new_q) = &result.new_quota {
println!();
println!("{}", highlight("Updated Quota:"));
for tier in &new_q.tiers {
println!(" {}: {:.1}%", tier.name, tier.utilization);
}
if let Some(rc) = &new_q.reset_credits {
println!(" Remaining Credits: {}", rc.available_count);
}
}
Ok(())
}
19 changes: 16 additions & 3 deletions src-tauri/src/cli/commands/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,15 @@ pub enum ProviderCommand {
/// Output raw quota result as JSON
#[arg(long)]
json: bool,
/// Redeem a rate limit reset credit to reset quota (Codex / OpenAI only)
#[arg(long)]
reset: bool,
/// Specific credit ID to consume (defaults to earliest expiring credit)
#[arg(long)]
credit_id: Option<String>,
/// Confirm consumption of a rate limit reset credit (safety gate; required to actually consume)
#[arg(long)]
confirm: bool,
},
/// Configure provider Usage Query
#[command(subcommand)]
Expand Down Expand Up @@ -922,9 +931,13 @@ pub fn execute(cmd: ProviderCommand, app: Option<AppType>) -> Result<(), AppErro
)
}
}
ProviderCommand::Quota { id, json } => {
provider_inspect::quota_provider(app_type, &id, json)
}
ProviderCommand::Quota {
id,
json,
reset,
credit_id,
confirm,
} => provider_inspect::quota_provider(app_type, &id, json, reset, credit_id, confirm),
ProviderCommand::UsageQuery(cmd) => provider_usage_query::execute(cmd, app_type),
ProviderCommand::Export { id, output } => export_provider(app_type, &id, output),
}
Expand Down
148 changes: 146 additions & 2 deletions src-tauri/src/cli/commands/provider_inspect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ use crate::app_config::AppType;
use crate::cli::i18n::texts;
use crate::cli::provider_quota::{
display_usage_plan_name, provider_display_name, query_quota, quota_reset_display,
quota_target_for_provider, usage_value_summary, ProviderUsageQuota, QuotaTarget,
quota_target_for_provider, reset_provider_quota, usage_value_summary, ProviderUsageQuota,
QuotaTarget, QuotaTargetKind,
};
use crate::cli::ui::{create_table, error, highlight, info, success, to_json, warning};
use crate::error::AppError;
Expand Down Expand Up @@ -424,7 +425,14 @@ fn print_fetched_models(models: &[String]) {
);
}

pub(crate) fn quota_provider(app_type: AppType, id: &str, json: bool) -> Result<(), AppError> {
pub(crate) fn quota_provider(
app_type: AppType,
id: &str,
json: bool,
reset: bool,
credit_id: Option<String>,
confirm: bool,
) -> Result<(), AppError> {
let state = get_state()?;
let providers = ProviderService::list(&state, app_type.clone())?;
let provider = providers
Expand All @@ -437,6 +445,123 @@ pub(crate) fn quota_provider(app_type: AppType, id: &str, json: bool) -> Result<
let output = if let Some(target) = target {
let runtime = tokio::runtime::Runtime::new()
.map_err(|e| AppError::Message(format!("Failed to create async runtime: {}", e)))?;

if reset {
let is_codex = matches!(&target.kind, QuotaTargetKind::CodexOAuth { .. })
|| matches!(&target.kind, QuotaTargetKind::SubscriptionTool { tool } if tool == "codex");
if !is_codex {
return Err(AppError::Message(
"Rate limit reset credits are only supported for Codex / OpenAI accounts.".to_string(),
));
}

let current_quota = match runtime.block_on(query_quota(&target)) {
Ok(ProviderUsageQuota::Subscription(q)) => q,
Ok(_) => {
return Err(AppError::Message("Unexpected quota response format".to_string()));
}
Err(e) => {
return Err(AppError::Message(format!("Failed to query current quota: {e}")));
}
};

let summary = current_quota.reset_credits.as_ref();
let available_count = summary.map(|s| s.available_count).unwrap_or(0);
if available_count == 0 {
return Err(AppError::Message(
"No rate limit reset credits are available for this account.".to_string(),
));
}

let credits = summary.map(|s| &s.credits[..]).unwrap_or(&[]);
let selected_credit = match &credit_id {
Some(cid) => credits.iter().find(|c| &c.id == cid).cloned().ok_or_else(|| {
AppError::Message(format!(
"Specified credit ID '{cid}' was not found in available credits."
))
})?,
None => credits.first().cloned().ok_or_else(|| {
AppError::Message("No available reset credit to consume.".to_string())
})?,
};

if !confirm {
if json {
let dry_run = serde_json::json!({
"dryRun": true,
"providerId": id,
"targetCredit": selected_credit,
"availableCount": available_count,
"message": "To redeem this credit and reset rate limits, run again with --confirm.",
});
println!(
"{}",
to_json(&dry_run).map_err(|source| AppError::JsonSerialize { source })?
);
return Ok(());
}

println!("{}", highlight("=== Rate Limit Reset Credit (Dry Run) ==="));
println!("Provider: {} ({})", provider_name, id);
println!("Credit ID: {}", selected_credit.id);
if let Some(title) = &selected_credit.title {
println!("Title: {}", title);
}
if let Some(expires) = &selected_credit.expires_at {
println!("Expires at: {}", expires);
}
println!("Available Cards: {}", available_count);
println!();
println!(
"{}",
warning("⚠ Consuming a reset credit is permanent and cannot be undone.")
);
println!(
"{}",
info(&format!(
"To consume this credit, re-run with --confirm:
cc-switch provider quota {} --reset --confirm",
id
))
);
return Ok(());
}

let result = runtime
.block_on(reset_provider_quota(&target, Some(&selected_credit.id)))
.map_err(AppError::Message)?;

if json {
println!(
"{}",
to_json(&result).map_err(|source| AppError::JsonSerialize { source })?
);
return Ok(());
}

println!(
"{}",
success("✓ Rate limit reset credit successfully redeemed!")
);
println!("Credit ID: {}", result.credit_id);
println!("Redeem Request ID: {}", result.redeem_request_id);
println!("Upstream Code: {}", result.code);
if let Some(win) = result.windows_reset {
println!("Windows Reset: {}", win);
}
if let Some(new_q) = &result.new_quota {
println!();
println!("{}", highlight("Updated Quota:"));
for tier in &new_q.tiers {
println!(" {}: {:.1}%", tier.name, tier.utilization);
}
if let Some(rc) = &new_q.reset_credits {
println!(" Remaining Credits: {}", rc.available_count);
}
}
return Ok(());
}

match runtime.block_on(query_quota(&target)) {
Ok(result) => quota_output_from_result(
app_type,
Expand All @@ -459,6 +584,11 @@ pub(crate) fn quota_provider(app_type: AppType, id: &str, json: bool) -> Result<
},
}
} else {
if reset {
return Err(AppError::Message(
"Rate limit reset credits are only supported for Codex / OpenAI accounts.".to_string(),
));
}
ProviderQuotaOutput {
app: app_type,
provider_id: id.to_string(),
Expand Down Expand Up @@ -699,6 +829,19 @@ fn push_subscription_quota_lines(
}
lines.push(line);
}
if let Some(reset_credits) = &quota.reset_credits {
if reset_credits.available_count > 0 {
lines.push(format!(
"Reset Credits: {} available",
reset_credits.available_count
));
for credit in &reset_credits.credits {
let expires = credit.expires_at.as_deref().unwrap_or("never");
let title = credit.title.as_deref().unwrap_or("Rate Limit Reset");
lines.push(format!(" - {} (expires: {}) [{}]", title, expires, credit.id));
}
}
}
if let Some(extra) = quota.extra_usage.as_ref().filter(|extra| extra.is_enabled) {
let mut parts = Vec::new();
if let Some(used) = extra.used_credits {
Expand Down Expand Up @@ -1235,6 +1378,7 @@ mod tests {
extra_usage: None,
error: None,
queried_at: Some(1_700_000_000_000),
reset_credits: None,
}
}

Expand Down
Loading