diff --git a/src-tauri/src/cli/commands/auth.rs b/src-tauri/src/cli/commands/auth.rs index 62913fd6..2474a5ed 100644 --- a/src-tauri/src/cli/commands/auth.rs +++ b/src-tauri/src/cli/commands/auth.rs @@ -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}; @@ -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, + /// Specific credit ID to consume (defaults to earliest expiring) + #[arg(long)] + credit_id: Option, + /// 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)] @@ -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), } } @@ -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, + credit_id: Option, + 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(()) +} diff --git a/src-tauri/src/cli/commands/provider.rs b/src-tauri/src/cli/commands/provider.rs index 201a4806..05092b9d 100644 --- a/src-tauri/src/cli/commands/provider.rs +++ b/src-tauri/src/cli/commands/provider.rs @@ -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, + /// Confirm consumption of a rate limit reset credit (safety gate; required to actually consume) + #[arg(long)] + confirm: bool, }, /// Configure provider Usage Query #[command(subcommand)] @@ -922,9 +931,13 @@ pub fn execute(cmd: ProviderCommand, app: Option) -> 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), } diff --git a/src-tauri/src/cli/commands/provider_inspect.rs b/src-tauri/src/cli/commands/provider_inspect.rs index 758ef6f2..a6ce57eb 100644 --- a/src-tauri/src/cli/commands/provider_inspect.rs +++ b/src-tauri/src/cli/commands/provider_inspect.rs @@ -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; @@ -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, + confirm: bool, +) -> Result<(), AppError> { let state = get_state()?; let providers = ProviderService::list(&state, app_type.clone())?; let provider = providers @@ -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, @@ -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(), @@ -699,6 +829,19 @@ fn push_subscription_quota_lines( } lines.push(line); } + if let Some(reset_credits) = "a.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 { @@ -1235,6 +1378,7 @@ mod tests { extra_usage: None, error: None, queried_at: Some(1_700_000_000_000), + reset_credits: None, } } diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index ebe59f4d..68f9d248 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -756,6 +756,34 @@ mod tests { } } + #[test] + fn parses_auth_reset_quota_subcommand() { + let cli = Cli::parse_from([ + "cc-switch", + "auth", + "reset-quota", + "--confirm", + "--credit-id", + "RateLimitResetCredit_456", + "--json", + ]); + + match cli.command { + Some(Commands::Auth(super::commands::auth::AuthCommand::ResetQuota { + account_id, + credit_id, + confirm, + json, + })) => { + assert!(confirm); + assert!(json); + assert_eq!(credit_id.as_deref(), Some("RateLimitResetCredit_456")); + assert_eq!(account_id, None); + } + _ => panic!("expected auth reset-quota command"), + } + } + #[cfg(unix)] #[test] fn parses_start_claude_subcommand() { @@ -1176,6 +1204,7 @@ mod tests { Some(Commands::Provider(super::commands::provider::ProviderCommand::Quota { id, json, + .. })) => { assert_eq!(id, "demo"); assert!(!json); @@ -1192,6 +1221,7 @@ mod tests { Some(Commands::Provider(super::commands::provider::ProviderCommand::Quota { id, json, + .. })) => { assert_eq!(id, "demo"); assert!(json); @@ -1200,6 +1230,37 @@ mod tests { } } + #[test] + fn parses_provider_quota_reset_subcommand() { + let cli = Cli::parse_from([ + "cc-switch", + "provider", + "quota", + "demo", + "--reset", + "--confirm", + "--credit-id", + "RateLimitResetCredit_123", + ]); + + match cli.command { + Some(Commands::Provider(super::commands::provider::ProviderCommand::Quota { + id, + json, + reset, + credit_id, + confirm, + })) => { + assert_eq!(id, "demo"); + assert!(!json); + assert!(reset); + assert!(confirm); + assert_eq!(credit_id.as_deref(), Some("RateLimitResetCredit_123")); + } + _ => panic!("expected provider quota reset command"), + } + } + #[test] fn parses_provider_usage_query_show_json_subcommand() { let cli = Cli::parse_from([ diff --git a/src-tauri/src/cli/provider_quota.rs b/src-tauri/src/cli/provider_quota.rs index 58bacbcd..e0337e9d 100644 --- a/src-tauri/src/cli/provider_quota.rs +++ b/src-tauri/src/cli/provider_quota.rs @@ -177,7 +177,23 @@ pub(crate) async fn query_quota(target: &QuotaTarget) -> Result, +) -> Result { + match &target.kind { + QuotaTargetKind::SubscriptionTool { tool } if tool == "codex" => { + crate::services::subscription::reset_codex_subscription_quota(credit_id).await + } + QuotaTargetKind::CodexOAuth { account_id } => { + crate::services::CodexOAuthService::reset_quota(account_id.as_deref(), credit_id).await + } + _ => Err("Rate limit reset credits are only supported for Codex / OpenAI accounts.".to_string()), + } +} pub(crate) fn display_usage_plan_name(item: &UsageData) -> Option<&str> { + item.plan_name.as_deref().filter(|value| { let trimmed = value.trim(); !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("default") diff --git a/src-tauri/src/cli/tui/runtime_systems/handlers.rs b/src-tauri/src/cli/tui/runtime_systems/handlers.rs index a7dc2311..4595f58d 100644 --- a/src-tauri/src/cli/tui/runtime_systems/handlers.rs +++ b/src-tauri/src/cli/tui/runtime_systems/handlers.rs @@ -2494,6 +2494,7 @@ mod tests { extra_usage: None, error: None, queried_at: Some(chrono::Utc::now().timestamp_millis()), + reset_credits: None, } } diff --git a/src-tauri/src/cli/tui/ui/providers.rs b/src-tauri/src/cli/tui/ui/providers.rs index bcd8a3a5..3ab19419 100644 --- a/src-tauri/src/cli/tui/ui/providers.rs +++ b/src-tauri/src/cli/tui/ui/providers.rs @@ -319,6 +319,7 @@ mod tests { extra_usage: None, error: None, queried_at: Some(chrono::Utc::now().timestamp_millis()), + reset_credits: None, }), ); data diff --git a/src-tauri/src/services/auth.rs b/src-tauri/src/services/auth.rs index 6e28a81b..3120c2ce 100644 --- a/src-tauri/src/services/auth.rs +++ b/src-tauri/src/services/auth.rs @@ -166,6 +166,31 @@ impl AuthService { } } + pub async fn get_quota( + auth_provider: &str, + account_id: Option<&str>, + ) -> Result { + let auth_provider = ensure_auth_provider(auth_provider)?; + match auth_provider { + AUTH_PROVIDER_CODEX_OAUTH => Ok(CodexOAuthService::get_quota(account_id).await), + _ => unreachable!(), + } + } + + pub async fn reset_quota( + auth_provider: &str, + account_id: Option<&str>, + credit_id: Option<&str>, + ) -> Result { + let auth_provider = ensure_auth_provider(auth_provider)?; + match auth_provider { + AUTH_PROVIDER_CODEX_OAUTH => { + CodexOAuthService::reset_quota(account_id, credit_id).await + } + _ => unreachable!(), + } + } + pub async fn logout(auth_provider: &str) -> Result<(), String> { let auth_provider = ensure_auth_provider(auth_provider)?; match auth_provider { diff --git a/src-tauri/src/services/codex_oauth.rs b/src-tauri/src/services/codex_oauth.rs index eaed339f..c2129520 100644 --- a/src-tauri/src/services/codex_oauth.rs +++ b/src-tauri/src/services/codex_oauth.rs @@ -198,6 +198,37 @@ impl CodexOAuthService { .await } + pub async fn reset_quota( + account_id: Option<&str>, + credit_id: Option<&str>, + ) -> Result { + let manager = Self::manager(); + let resolved_account_id = match account_id + .map(str::trim) + .filter(|account_id| !account_id.is_empty()) + { + Some(account_id) => Some(account_id.to_string()), + None => manager.default_account_id().await, + }; + + let Some(account_id) = resolved_account_id else { + return Err("No ChatGPT account available".to_string()); + }; + + let token = manager + .get_valid_token_for_account(&account_id) + .await + .map_err(|error| format!("Codex OAuth token unavailable: {error}"))?; + + crate::services::subscription::consume_codex_reset_credit( + &token, + Some(&account_id), + credit_id, + "codex_oauth", + ) + .await + } + pub async fn get_models( account_id: Option<&str>, ) -> Result, String> { diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index c252f530..74427ce2 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -90,6 +90,7 @@ fn make_error(msg: String) -> SubscriptionQuota { extra_usage: None, error: Some(msg), queried_at: Some(now_millis()), + reset_credits: None, } } @@ -122,6 +123,7 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { extra_usage: None, error: Some(format!("Authentication failed (HTTP {status})")), queried_at: Some(now_millis()), + reset_credits: None, }; } @@ -188,6 +190,7 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { extra_usage: None, error: None, queried_at: Some(now_millis()), + reset_credits: None, } } @@ -291,6 +294,7 @@ async fn query_zhipu(api_key: &str) -> SubscriptionQuota { extra_usage: None, error: Some(format!("Authentication failed (HTTP {status})")), queried_at: Some(now_millis()), + reset_credits: None, }; } @@ -335,6 +339,7 @@ async fn query_zhipu(api_key: &str) -> SubscriptionQuota { extra_usage: None, error: None, queried_at: Some(now_millis()), + reset_credits: None, } } @@ -374,6 +379,7 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { extra_usage: None, error: Some(format!("Authentication failed (HTTP {status})")), queried_at: Some(now_millis()), + reset_credits: None, }; } @@ -456,6 +462,7 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { extra_usage: None, error: None, queried_at: Some(now_millis()), + reset_credits: None, } } @@ -541,6 +548,7 @@ async fn query_opencode_go(api_key: &str) -> Result { extra_usage: None, error: Some(format!("Authentication failed (HTTP {status})")), queried_at: Some(now_millis()), + reset_credits: None, }); } if !status.is_success() { @@ -573,6 +581,7 @@ async fn query_opencode_go(api_key: &str) -> Result { extra_usage: None, error: None, queried_at: Some(now_millis()), + reset_credits: None, }) } @@ -592,6 +601,7 @@ pub async fn get_coding_plan_quota( extra_usage: None, error: None, queried_at: None, + reset_credits: None, }); } @@ -607,6 +617,7 @@ pub async fn get_coding_plan_quota( extra_usage: None, error: None, queried_at: None, + reset_credits: None, }) } }; diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index dd7f9f68..b573ab62 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -57,7 +57,12 @@ pub use s3_sync::{S3RemoteInfo, S3SyncService, S3SyncSummary}; pub use skill::{ImportSkillSelection, MigrationResult, SkillService, SkillStorageLocation}; pub use speedtest::{EndpointLatency, SpeedtestService}; pub use stream_check::{HealthStatus, StreamCheckConfig, StreamCheckResult, StreamCheckService}; -pub use subscription::{CredentialStatus, ExtraUsage, QuotaTier, SubscriptionQuota}; +#[allow(unused_imports)] +pub use subscription::{ + consume_codex_reset_credit, reset_codex_subscription_quota, CodexResetConsumeResult, + CodexResetCredit, CodexResetCreditsSummary, CredentialStatus, ExtraUsage, QuotaTier, + SubscriptionQuota, +}; #[allow(unused_imports)] pub use usage_stats::{ DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats, diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 40bb5848..83f6f9e5 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; use crate::config; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -31,6 +32,39 @@ pub struct ExtraUsage { pub currency: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CodexResetCredit { + pub id: String, + pub title: Option, + pub description: Option, + pub reset_type: Option, + pub status: Option, + pub granted_at: Option, + pub expires_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CodexResetCreditsSummary { + pub available_count: usize, + pub applicable_available_count: Option, + pub credits: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexResetConsumeResult { + pub success: bool, + pub code: String, + pub credit_id: String, + pub redeem_request_id: String, + pub windows_reset: Option, + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub new_quota: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SubscriptionQuota { @@ -42,6 +76,8 @@ pub struct SubscriptionQuota { pub extra_usage: Option, pub error: Option, pub queried_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reset_credits: Option, } impl SubscriptionQuota { @@ -55,6 +91,7 @@ impl SubscriptionQuota { extra_usage: None, error: None, queried_at: None, + reset_credits: None, } } @@ -68,6 +105,7 @@ impl SubscriptionQuota { extra_usage: None, error: Some(message), queried_at: Some(now_millis()), + reset_credits: None, } } } @@ -355,6 +393,7 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { extra_usage, error: None, queried_at: Some(now_millis()), + reset_credits: None, } } @@ -521,9 +560,110 @@ struct CodexRateLimit { secondary_window: Option, } +#[derive(Deserialize)] +struct CodexUsageResetCredits { + available_count: Option, + applicable_available_count: Option, +} + #[derive(Deserialize)] struct CodexUsageResponse { rate_limit: Option, + rate_limit_reset_credits: Option, +} + +#[derive(Deserialize)] +pub(crate) struct RawCodexResetCredit { + pub id: Option, + pub reset_type: Option, + pub is_supported_by_plan: Option, + pub status: Option, + pub granted_at: Option, + pub expires_at: Option, + pub title: Option, + pub description: Option, +} + +#[derive(Deserialize)] +struct RawCodexResetCreditsResponse { + credits: Option>, + available_count: Option, +} + +pub(crate) fn filter_and_sort_codex_reset_credits( + raw_credits: Vec, +) -> Vec { + let mut valid: Vec = raw_credits + .into_iter() + .filter_map(|c| { + let id = c.id?.trim().to_string(); + if id.is_empty() { + return None; + } + if c.reset_type.as_deref() != Some("codex_rate_limits") { + return None; + } + if c.status.as_deref() != Some("available") { + return None; + } + if c.is_supported_by_plan == Some(false) { + return None; + } + let granted_at = c.granted_at.filter(|s| !s.trim().is_empty())?; + Some(CodexResetCredit { + id, + title: c.title, + description: c.description, + reset_type: c.reset_type, + status: c.status, + granted_at: Some(granted_at), + expires_at: c.expires_at, + }) + }) + .collect(); + + valid.sort_by(|a, b| match (&a.expires_at, &b.expires_at) { + (Some(ea), Some(eb)) => ea.cmp(eb), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }); + + valid +} + +pub(crate) async fn fetch_codex_reset_credits( + client: &reqwest::Client, + access_token: &str, + account_id: Option<&str>, +) -> Result, String> { + let mut request = client + .get("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits") + .header("Authorization", format!("Bearer {access_token}")) + .header("User-Agent", "codex-cli") + .header("Accept", "application/json"); + + if let Some(account_id) = account_id { + request = request.header("ChatGPT-Account-Id", account_id); + } + + let response = request + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("Network error fetching reset credits: {e}"))?; + + let status = response.status(); + if !status.is_success() { + return Err(format!("Failed to fetch reset credits (HTTP {status})")); + } + + let raw: RawCodexResetCreditsResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse reset credits response: {e}"))?; + + Ok(filter_and_sort_codex_reset_credits(raw.credits.unwrap_or_default())) } fn now_millis() -> i64 { @@ -633,6 +773,29 @@ pub(crate) async fn query_codex_quota( } } + let reset_credits = match fetch_codex_reset_credits(&client, access_token, account_id).await { + Ok(credits) => { + let available_count = credits.len(); + let applicable_available_count = body + .rate_limit_reset_credits + .as_ref() + .and_then(|rc| rc.applicable_available_count); + Some(CodexResetCreditsSummary { + available_count, + applicable_available_count, + credits, + }) + } + 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(), + }), + }; + SubscriptionQuota { tool: tool_label.to_string(), credential_status: CredentialStatus::Valid, @@ -642,6 +805,141 @@ pub(crate) async fn query_codex_quota( extra_usage: None, error: None, queried_at: Some(now_millis()), + reset_credits, + } +} + + +pub async fn consume_codex_reset_credit( + access_token: &str, + account_id: Option<&str>, + credit_id: Option<&str>, + tool_label: &str, +) -> Result { + let client = crate::proxy::http_client::get(); + + // 1. Fresh GET to fetch currently available credits (Fail-closed) + let available_credits = fetch_codex_reset_credits(&client, access_token, account_id).await?; + if available_credits.is_empty() { + return Err("No available rate limit reset credits found for this account.".to_string()); + } + + // 2. Select target credit: specified ID or earliest expiring + let target_credit = match credit_id { + Some(cid) => available_credits + .into_iter() + .find(|c| c.id == cid) + .ok_or_else(|| { + format!("Specified credit ID '{cid}' is not available or valid for this account.") + })?, + None => available_credits + .into_iter() + .next() + .ok_or_else(|| "No available reset credit to consume.".to_string())?, + }; + + let target_credit_id = target_credit.id; + let redeem_request_id = Uuid::new_v4().to_string(); + + // 3. Send consume request + let mut request = client + .post("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume") + .header("Authorization", format!("Bearer {access_token}")) + .header("User-Agent", "codex-cli") + .header("Accept", "application/json") + .header("Content-Type", "application/json"); + + if let Some(account_id) = account_id { + request = request.header("ChatGPT-Account-Id", account_id); + } + + let payload = serde_json::json!({ + "credit_id": target_credit_id, + "redeem_request_id": redeem_request_id, + }); + + let response = request + .json(&payload) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|e| format!("Network error consuming reset credit: {e}"))?; + + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(format!("Authentication failed (HTTP {status}). Please re-login.")); + } + + if status == reqwest::StatusCode::UNPROCESSABLE_ENTITY { + let body = response.text().await.unwrap_or_default(); + return Err(format!("OpenAI rejected reset credit consumption (HTTP 422): {body}")); + } + + let body_text = response.text().await.unwrap_or_default(); + let resp_json: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| { + format!("Failed to parse consume response (HTTP {status}): {body_text} ({e})") + })?; + + let code = resp_json + .get("code") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let windows_reset = resp_json.get("windows_reset").and_then(|v| v.as_i64()); + + // CPA-Helper keeper rules: "reset" and "already_redeemed" are both considered successful + if code == "reset" || code == "already_redeemed" { + // 4. Re-inspect usage + let new_quota = query_codex_quota( + access_token, + account_id, + tool_label, + "Authentication failed on quota re-inspection.", + ) + .await; + + Ok(CodexResetConsumeResult { + success: true, + code, + credit_id: target_credit_id, + redeem_request_id, + windows_reset, + message: None, + new_quota: Some(new_quota), + }) + } else if code == "nothing_to_reset" { + Err("Rate limit window is not currently restricted (nothing_to_reset).".to_string()) + } else if code == "no_credit" { + Err("No valid reset credits available to consume (no_credit).".to_string()) + } else { + Err(format!( + "Failed to redeem credit: upstream returned code '{code}' (HTTP {status}): {body_text}" + )) + } +} + +pub async fn reset_codex_subscription_quota( + credit_id: Option<&str>, +) -> Result { + let (token, account_id, status, message) = read_codex_credentials(); + match status { + CredentialStatus::NotFound => Err("Codex credentials not found".to_string()), + CredentialStatus::ParseError => { + Err(message.unwrap_or_else(|| "Failed to parse Codex credentials".to_string())) + } + CredentialStatus::Expired => { + let Some(token) = token else { + return Err(message.unwrap_or_else(|| "Codex token expired".to_string())); + }; + consume_codex_reset_credit(&token, account_id.as_deref(), credit_id, "codex").await + } + CredentialStatus::Valid => { + let Some(token) = token else { + return Err("Codex access_token is empty or missing".to_string()); + }; + consume_codex_reset_credit(&token, account_id.as_deref(), credit_id, "codex").await + } } } @@ -1115,6 +1413,7 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { extra_usage: None, error: None, queried_at: Some(now_millis()), + reset_credits: None, } } @@ -1413,4 +1712,101 @@ mod tests { assert_eq!(window_seconds_to_tier_name(7_200), "2_hour"); assert_eq!(window_seconds_to_tier_name(172_800), "2_day"); } + + #[test] + fn filter_and_sort_codex_reset_credits_applies_strict_rules() { + let raw = vec![ + RawCodexResetCredit { + id: Some("credit-expired".to_string()), + reset_type: Some("codex_rate_limits".to_string()), + status: Some("available".to_string()), + is_supported_by_plan: Some(true), + granted_at: Some("2025-01-01T00:00:00Z".to_string()), + expires_at: Some("2026-10-22T00:00:00Z".to_string()), + title: Some("Card Later".to_string()), + description: None, + }, + RawCodexResetCredit { + id: Some("credit-earliest".to_string()), + reset_type: Some("codex_rate_limits".to_string()), + status: Some("available".to_string()), + is_supported_by_plan: Some(true), + granted_at: Some("2025-01-01T00:00:00Z".to_string()), + expires_at: Some("2026-10-04T00:00:00Z".to_string()), + title: Some("Card Earliest".to_string()), + description: None, + }, + // Wrong reset type + RawCodexResetCredit { + id: Some("credit-other-type".to_string()), + reset_type: Some("chatgpt_messages".to_string()), + status: Some("available".to_string()), + is_supported_by_plan: Some(true), + granted_at: Some("2025-01-01T00:00:00Z".to_string()), + expires_at: Some("2026-10-01T00:00:00Z".to_string()), + title: None, + description: None, + }, + // Redeemed / unavailable status + RawCodexResetCredit { + id: Some("credit-redeemed".to_string()), + reset_type: Some("codex_rate_limits".to_string()), + status: Some("redeemed".to_string()), + is_supported_by_plan: Some(true), + granted_at: Some("2025-01-01T00:00:00Z".to_string()), + expires_at: Some("2026-10-01T00:00:00Z".to_string()), + title: None, + description: None, + }, + // Unsupported by plan + RawCodexResetCredit { + id: Some("credit-unsupported".to_string()), + reset_type: Some("codex_rate_limits".to_string()), + status: Some("available".to_string()), + is_supported_by_plan: Some(false), + granted_at: Some("2025-01-01T00:00:00Z".to_string()), + expires_at: Some("2026-10-01T00:00:00Z".to_string()), + title: None, + description: None, + }, + // Missing granted_at + RawCodexResetCredit { + id: Some("credit-no-grant".to_string()), + reset_type: Some("codex_rate_limits".to_string()), + status: Some("available".to_string()), + is_supported_by_plan: Some(true), + granted_at: None, + expires_at: Some("2026-10-01T00:00:00Z".to_string()), + title: None, + description: None, + }, + ]; + + let filtered = filter_and_sort_codex_reset_credits(raw); + assert_eq!(filtered.len(), 2); + assert_eq!(filtered[0].id, "credit-earliest"); + assert_eq!(filtered[1].id, "credit-expired"); + } + + #[test] + fn parse_codex_usage_with_reset_credits() { + let json = serde_json::json!({ + "rate_limit": { + "primary_window": { + "used_percent": 12.5, + "limit_window_seconds": 18000, + "reset_at": 1700000000 + } + }, + "rate_limit_reset_credits": { + "available_count": 3, + "applicable_available_count": 3 + } + }); + + let resp: CodexUsageResponse = serde_json::from_value(json).expect("deserialize CodexUsageResponse"); + let credits = resp.rate_limit_reset_credits.expect("credits present"); + assert_eq!(credits.available_count, Some(3)); + assert_eq!(credits.applicable_available_count, Some(3)); + } }