diff --git a/src-tauri/src/cli/commands/auth.rs b/src-tauri/src/cli/commands/auth.rs index 2474a5ed..824e9c25 100644 --- a/src-tauri/src/cli/commands/auth.rs +++ b/src-tauri/src/cli/commands/auth.rs @@ -354,37 +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.", @@ -397,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); @@ -412,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(()); } @@ -423,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)?; @@ -446,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 a6ce57eb..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)))?; @@ -451,45 +451,68 @@ pub(crate) fn quota_provider( || 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(), + "Rate limit reset credits are only supported for Codex / OpenAI accounts." + .to_string(), )); } + 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(_) => { - return Err(AppError::Message("Unexpected quota response format".to_string())); + return Err(AppError::Message( + "Unexpected quota response format".to_string(), + )); } Err(e) => { - return Err(AppError::Message(format!("Failed to query current quota: {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()) - })?, + 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.", @@ -503,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); @@ -519,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(()); @@ -549,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(()); } @@ -586,7 +614,8 @@ pub(crate) fn quota_provider( } else { if reset { return Err(AppError::Message( - "Rate limit reset credits are only supported for Codex / OpenAI accounts.".to_string(), + "Rate limit reset credits are only supported for Codex / OpenAI accounts." + .to_string(), )); } ProviderQuotaOutput { @@ -830,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 @@ -838,7 +876,10 @@ fn push_subscription_quota_lines( 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)); + lines.push(format!( + " - {} (expires: {}) [{}]", + title, expires, credit.id + )); } } } 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/cli/provider_quota.rs b/src-tauri/src/cli/provider_quota.rs index e0337e9d..dfaef975 100644 --- a/src-tauri/src/cli/provider_quota.rs +++ b/src-tauri/src/cli/provider_quota.rs @@ -177,7 +177,6 @@ pub(crate) async fn query_quota(target: &QuotaTarget) -> Result, @@ -189,11 +188,12 @@ pub(crate) async fn reset_provider_quota( 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()), + _ => 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/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index 74427ce2..1717a663 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -123,7 +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, + reset_credits: None, }; } @@ -294,7 +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, + reset_credits: None, }; } @@ -379,7 +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, + reset_credits: None, }; } @@ -548,7 +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, + reset_credits: None, }); } if !status.is_success() { @@ -601,7 +601,7 @@ pub async fn get_coding_plan_quota( extra_usage: None, error: None, queried_at: None, - reset_credits: None, + reset_credits: None, }); } @@ -617,7 +617,7 @@ pub async fn get_coding_plan_quota( extra_usage: None, error: None, queried_at: None, - reset_credits: None, + reset_credits: None, }) } }; diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 83f6f9e5..019d46ea 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -2,8 +2,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; -use uuid::Uuid; use crate::config; +use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -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)] @@ -663,7 +666,9 @@ pub(crate) async fn fetch_codex_reset_credits( .await .map_err(|e| format!("Failed to parse reset credits response: {e}"))?; - Ok(filter_and_sort_codex_reset_credits(raw.credits.unwrap_or_default())) + Ok(filter_and_sort_codex_reset_credits( + raw.credits.unwrap_or_default(), + )) } fn now_millis() -> i64 { @@ -784,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 { @@ -809,7 +822,6 @@ pub(crate) async fn query_codex_quota( } } - pub async fn consume_codex_reset_credit( access_token: &str, account_id: Option<&str>, @@ -867,12 +879,16 @@ pub async fn consume_codex_reset_credit( let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return Err(format!("Authentication failed (HTTP {status}). Please re-login.")); + 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}")); + return Err(format!( + "OpenAI rejected reset credit consumption (HTTP 422): {body}" + )); } let body_text = response.text().await.unwrap_or_default(); @@ -1804,7 +1820,8 @@ mod tests { } }); - let resp: CodexUsageResponse = serde_json::from_value(json).expect("deserialize CodexUsageResponse"); + 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));