diff --git a/src-tauri/src/cli/commands/auth.rs b/src-tauri/src/cli/commands/auth.rs index 015f1caf..824e9c25 100644 --- a/src-tauri/src/cli/commands/auth.rs +++ b/src-tauri/src/cli/commands/auth.rs @@ -354,41 +354,41 @@ fn reset_quota( confirm: bool, json: bool, ) -> Result<(), AppError> { + // Resolve the default exactly once: inspection and consumption must target the same account. + let account_id = match account_id { + Some(id) => normalize_account_id(&id)?.to_string(), + None => runtime + .block_on(AuthService::get_status(AUTH_PROVIDER_CODEX_OAUTH)) + .map_err(AppError::Message)? + .default_account_id + .ok_or_else(|| { + AppError::localized( + "quota.reset.no_account", + "没有可用的 ChatGPT 账号", + "No ChatGPT account available", + ) + })?, + }; let quota = runtime .block_on(AuthService::get_quota( AUTH_PROVIDER_CODEX_OAUTH, - account_id.as_deref(), + Some(&account_id), )) .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()) - })?, - }; + let selected_credit = super::quota_reset::select_credit("a, credit_id.as_deref())?; + let available_count = quota + .reset_credits + .as_ref() + .map(|s| s.available_count) + .unwrap_or(0); + let command = super::quota_reset::auth_confirmation(&account_id, &selected_credit.id)?; if !confirm { if json { let dry_run = serde_json::json!({ "dryRun": true, + "accountId": account_id, + "confirmationCommand": command, "targetCredit": selected_credit, "availableCount": available_count, "message": "To redeem this credit and reset rate limits, run again with --confirm.", @@ -401,6 +401,15 @@ fn reset_quota( } println!("{}", highlight("=== Rate Limit Reset Credit (Dry Run) ===")); + println!( + "{}: {}", + if crate::cli::i18n::is_chinese() { + "账号 ID" + } else { + "Account ID" + }, + account_id + ); println!("Credit ID: {}", selected_credit.id); if let Some(title) = &selected_credit.title { println!("Title: {}", title); @@ -416,10 +425,14 @@ fn reset_quota( ); println!( "{}", - info( - "To consume this credit, re-run with --confirm: - cc-switch auth reset-quota --confirm" - ) + info(&format!( + "{}\n {command}", + if crate::cli::i18n::is_chinese() { + "确认兑换此卡,请执行:" + } else { + "To consume this credit, run:" + } + )) ); return Ok(()); } @@ -427,7 +440,7 @@ fn reset_quota( let result = runtime .block_on(AuthService::reset_quota( AUTH_PROVIDER_CODEX_OAUTH, - account_id.as_deref(), + Some(&account_id), Some(&selected_credit.id), )) .map_err(AppError::Message)?; @@ -450,15 +463,6 @@ fn reset_quota( 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); - } - } + super::quota_reset::print_updated_quota(result.new_quota.as_ref()); Ok(()) } diff --git a/src-tauri/src/cli/commands/mod.rs b/src-tauri/src/cli/commands/mod.rs index b258b3fe..27d351ba 100644 --- a/src-tauri/src/cli/commands/mod.rs +++ b/src-tauri/src/cli/commands/mod.rs @@ -20,6 +20,7 @@ pub mod provider_input; mod provider_inspect; pub mod provider_usage_query; pub mod proxy; +mod quota_reset; pub mod sessions; pub mod settings; pub mod skills; diff --git a/src-tauri/src/cli/commands/provider_inspect.rs b/src-tauri/src/cli/commands/provider_inspect.rs index f1072a02..244c613e 100644 --- a/src-tauri/src/cli/commands/provider_inspect.rs +++ b/src-tauri/src/cli/commands/provider_inspect.rs @@ -442,7 +442,7 @@ pub(crate) fn quota_provider( let target = quota_target_for_provider(&app_type, id, provider); let queried_at = chrono::Utc::now().timestamp_millis(); - let output = if let Some(target) = target { + let output = if let Some(mut target) = target { let runtime = tokio::runtime::Runtime::new() .map_err(|e| AppError::Message(format!("Failed to create async runtime: {}", e)))?; @@ -456,6 +456,23 @@ pub(crate) fn quota_provider( )); } + if let QuotaTargetKind::CodexOAuth { account_id } = &mut target.kind { + if account_id.is_none() { + *account_id = Some( + runtime + .block_on(CodexOAuthService::get_status()) + .default_account_id + .ok_or_else(|| { + AppError::localized( + "quota.reset.no_account", + "没有可用的 ChatGPT 账号", + "No ChatGPT account available", + ) + })?, + ); + } + } + let current_quota = match runtime.block_on(query_quota(&target)) { Ok(ProviderUsageQuota::Subscription(q)) => q, Ok(_) => { @@ -470,35 +487,32 @@ pub(crate) fn quota_provider( } }; - 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()) - })?, + let selected_credit = + super::quota_reset::select_credit(¤t_quota, credit_id.as_deref())?; + let available_count = current_quota + .reset_credits + .as_ref() + .map(|s| s.available_count) + .unwrap_or(0); + let account_id = match &target.kind { + QuotaTargetKind::CodexOAuth { account_id } => account_id.as_deref(), + _ => None, }; + let command = super::quota_reset::provider_confirmation( + app_type.as_str(), + id, + &selected_credit.id, + account_id, + )?; if !confirm { if json { let dry_run = serde_json::json!({ "dryRun": true, "providerId": id, + "app": app_type, + "accountId": account_id, + "confirmationCommand": command, "targetCredit": selected_credit, "availableCount": available_count, "message": "To redeem this credit and reset rate limits, run again with --confirm.", @@ -512,6 +526,17 @@ pub(crate) fn quota_provider( println!("{}", highlight("=== Rate Limit Reset Credit (Dry Run) ===")); println!("Provider: {} ({})", provider_name, id); + if let Some(account_id) = account_id { + println!( + "{}: {}", + if crate::cli::i18n::is_chinese() { + "账号 ID" + } else { + "Account ID" + }, + account_id + ); + } println!("Credit ID: {}", selected_credit.id); if let Some(title) = &selected_credit.title { println!("Title: {}", title); @@ -528,9 +553,12 @@ pub(crate) fn quota_provider( println!( "{}", info(&format!( - "To consume this credit, re-run with --confirm: - cc-switch provider quota {} --reset --confirm", - id + "{}\n {command}", + if crate::cli::i18n::is_chinese() { + "确认兑换此卡,请执行:" + } else { + "To consume this credit, run:" + } )) ); return Ok(()); @@ -558,16 +586,7 @@ pub(crate) fn quota_provider( 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); - } - } + super::quota_reset::print_updated_quota(result.new_quota.as_ref()); return Ok(()); } @@ -840,7 +859,16 @@ fn push_subscription_quota_lines( lines.push(line); } if let Some(reset_credits) = "a.reset_credits { - if reset_credits.available_count > 0 { + if reset_credits.inspection_error.is_some() { + lines.push( + if crate::cli::i18n::is_chinese() { + "重置卡查询失败,请重试查询。" + } else { + "Reset credit inspection failed; retry the query." + } + .to_string(), + ); + } else if reset_credits.available_count > 0 { lines.push(format!( "Reset Credits: {} available", reset_credits.available_count diff --git a/src-tauri/src/cli/commands/quota_reset.rs b/src-tauri/src/cli/commands/quota_reset.rs new file mode 100644 index 00000000..24e978ed --- /dev/null +++ b/src-tauri/src/cli/commands/quota_reset.rs @@ -0,0 +1,307 @@ +//! Shared validation and output for the two explicit reset-credit commands. +use crate::cli::i18n::is_chinese; +use crate::cli::ui::{highlight, warning}; +use crate::error::AppError; +use crate::services::{CodexResetCredit, CredentialStatus, SubscriptionQuota}; + +pub(super) fn quota_error(quota: &SubscriptionQuota) -> Option { + if quota.success && quota.credential_status == CredentialStatus::Valid && quota.error.is_none() + { + return None; + } + Some( + quota + .error + .as_deref() + .or(quota.credential_message.as_deref()) + .map(str::to_owned) + .unwrap_or_else(|| { + if is_chinese() { + format!("额度查询失败(凭据状态:{:?})", quota.credential_status) + } else { + format!( + "Quota query failed (credential status: {:?})", + quota.credential_status + ) + } + }), + ) +} + +pub(super) fn select_credit( + quota: &SubscriptionQuota, + credit_id: Option<&str>, +) -> Result { + if let Some(error) = quota_error(quota) { + return Err(AppError::Message(error)); + } + let summary = quota.reset_credits.as_ref().ok_or_else(|| { + AppError::localized( + "quota.reset.inspection_missing", + "重置卡查询结果缺失,请重试", + "Reset credit inspection is unavailable; retry the query", + ) + })?; + if summary.inspection_error.is_some() + || (summary.available_count > 0 && summary.credits.is_empty()) + { + return Err(AppError::localized( + "quota.reset.inspection_failed", + "重置卡查询失败或不完整,请重试", + "Reset credit inspection failed or is incomplete; retry the query", + )); + } + let credits = &summary.credits; + match credit_id { + Some(id) => credits.iter().find(|c| c.id == id), + None => credits.first(), + } + .cloned() + .ok_or_else(|| { + AppError::localized( + "quota.reset.credit_unavailable", + "没有可用的目标重置卡,请重新查询额度", + "No matching reset credit is available. Query the quota again.", + ) + }) +} + +pub(super) fn auth_confirmation(account_id: &str, credit_id: &str) -> Result { + confirmation_command(&[ + "cc-switch", + "auth", + "reset-quota", + "--account-id", + account_id, + "--credit-id", + credit_id, + "--confirm", + ]) +} + +pub(super) fn provider_confirmation( + app: &str, + provider_id: &str, + credit_id: &str, + account_id: Option<&str>, +) -> Result { + // Managed OAuth confirmation uses the account entry point to avoid re-resolving + // mutable provider/default-account bindings in a later invocation. + if let Some(account_id) = account_id { + return auth_confirmation(account_id, credit_id); + } + confirmation_command(&[ + "cc-switch", + "--app", + app, + "provider", + "quota", + provider_id, + "--reset", + "--credit-id", + credit_id, + "--confirm", + ]) +} + +pub(super) fn confirmation_command(args: &[&str]) -> Result { + args.iter() + .map(|arg| { + shlex::try_quote(arg).map(|q| q.into_owned()).map_err(|_| { + AppError::localized( + "quota.reset.invalid_argument", + "重置参数包含无效字符", + "Invalid character in reset argument", + ) + }) + }) + .collect::, _>>() + .map(|args| args.join(" ")) +} + +pub(super) fn print_updated_quota(quota: Option<&SubscriptionQuota>) { + let Some(quota) = quota else { + return; + }; + if let Some(error) = quota_error(quota) { + let message = if is_chinese() { + format!("重置卡已兑换,但额度复查失败:{error}。请勿重复兑换。") + } else { + format!( + "Credit redeemed, but quota re-inspection failed: {error}. Do not redeem again." + ) + }; + println!("{}", warning(&message)); + return; + } + println!(); + println!( + "{}", + highlight(if is_chinese() { + "更新后的额度:" + } else { + "Updated Quota:" + }) + ); + for tier in "a.tiers { + println!(" {}: {:.1}%", tier.name, tier.utilization); + } + if let Some(rc) = "a.reset_credits { + if rc.inspection_error.is_some() { + println!( + "{}", + warning(if is_chinese() { + "额度已更新,但剩余重置卡查询失败,请重试查询。" + } else { + "Quota updated, but remaining reset credits could not be inspected; retry the query." + }) + ); + return; + } + println!( + " {}: {}", + if is_chinese() { + "剩余重置卡" + } else { + "Remaining Credits" + }, + rc.available_count + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn failed_inspection_is_not_reported_as_zero_credits() { + let quota = + SubscriptionQuota::error("codex", CredentialStatus::Expired, "token rejected".into()); + assert!(select_credit("a, None) + .unwrap_err() + .to_string() + .contains("token rejected")); + assert_eq!(quota_error("a).as_deref(), Some("token rejected")); + assert!(quota_error(&SubscriptionQuota::not_found("codex")).is_some()); + } + #[test] + fn confirmation_preserves_target_and_quotes_shell_metacharacters() { + let args = [ + "cc-switch", + "auth", + "reset-quota", + "--account-id", + "account B", + "--credit-id", + "card'$(touch /tmp/nope)", + "--confirm", + ]; + let command = auth_confirmation("account B", "card'$(touch /tmp/nope)").unwrap(); + assert_eq!(shlex::split(&command).unwrap(), args); + assert!(confirmation_command(&["bad\0arg"]).is_err()); + } + #[test] + fn provider_confirmation_keeps_app_provider_and_selected_credit() { + let command = + provider_confirmation("codex", "provider b", "specific-credit", None).unwrap(); + assert_eq!( + shlex::split(&command).unwrap(), + [ + "cc-switch", + "--app", + "codex", + "provider", + "quota", + "provider b", + "--reset", + "--credit-id", + "specific-credit", + "--confirm" + ] + ); + } + #[test] + fn managed_provider_confirmation_pins_the_resolved_account() { + let command = provider_confirmation( + "codex", + "provider", + "specific-credit", + Some("resolved-account"), + ) + .unwrap(); + assert_eq!( + shlex::split(&command).unwrap(), + [ + "cc-switch", + "auth", + "reset-quota", + "--account-id", + "resolved-account", + "--credit-id", + "specific-credit", + "--confirm" + ] + ); + } + + #[test] + fn missing_or_incomplete_credit_inspection_is_not_an_empty_result() { + let mut quota: SubscriptionQuota = serde_json::from_value(serde_json::json!({ + "tool":"codex", "credentialStatus":"valid", "success":true, "tiers":[] + })) + .unwrap(); + assert!(matches!( + select_credit("a, None).unwrap_err(), + AppError::Localized { + key: "quota.reset.inspection_missing", + .. + } + )); + quota.reset_credits = Some(crate::services::CodexResetCreditsSummary { + available_count: 1, + applicable_available_count: None, + credits: vec![], + inspection_error: None, + }); + assert!(matches!( + select_credit("a, None).unwrap_err(), + AppError::Localized { + key: "quota.reset.inspection_failed", + .. + } + )); + quota.reset_credits.as_mut().unwrap().available_count = 0; + assert!(matches!( + select_credit("a, None).unwrap_err(), + AppError::Localized { + key: "quota.reset.credit_unavailable", + .. + } + )); + quota.reset_credits.as_mut().unwrap().inspection_error = Some("failed".into()); + assert!(matches!( + select_credit("a, None).unwrap_err(), + AppError::Localized { + key: "quota.reset.inspection_failed", + .. + } + )); + } + + #[test] + fn selection_preserves_specific_credit_and_refuses_missing_credit() { + let quota: SubscriptionQuota = serde_json::from_value(serde_json::json!({ + "tool":"codex", "credentialStatus":"valid", "success":true, "tiers":[], + "resetCredits":{"availableCount":2,"credits":[ + {"id":"first"},{"id":"selected"} + ]} + })) + .unwrap(); + assert_eq!( + select_credit("a, Some("selected")).unwrap().id, + "selected" + ); + assert!(select_credit("a, Some("missing")).is_err()); + assert!(quota_error("a).is_none()); + } +} diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 37ad3efe..019d46ea 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -50,6 +50,9 @@ pub struct CodexResetCreditsSummary { pub available_count: usize, pub applicable_available_count: Option, pub credits: Vec, + /// A successful usage query does not imply successful credit inspection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inspection_error: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -786,16 +789,24 @@ pub(crate) async fn query_codex_quota( available_count, applicable_available_count, credits, + inspection_error: None, }) } - Err(_) => body - .rate_limit_reset_credits - .as_ref() - .map(|rc| CodexResetCreditsSummary { - available_count: rc.available_count.unwrap_or(0), - applicable_available_count: rc.applicable_available_count, - credits: Vec::new(), - }), + Err(_) => Some(CodexResetCreditsSummary { + available_count: body + .rate_limit_reset_credits + .as_ref() + .and_then(|rc| rc.available_count) + .unwrap_or(0), + applicable_available_count: body + .rate_limit_reset_credits + .as_ref() + .and_then(|rc| rc.applicable_available_count), + credits: Vec::new(), + inspection_error: Some( + "Reset credit inspection failed; retry the query before redeeming.".into(), + ), + }), }; SubscriptionQuota {