Skip to content
Open
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
40 changes: 33 additions & 7 deletions src-tauri/src/api/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,21 @@ pub async fn fetch_chatgpt_account_metadata(
account: &StoredAccount,
) -> Result<ChatGptAccountMetadata> {
let (access_token, chatgpt_account_id) = extract_chatgpt_auth(account)?;
let response =
let mut response =
send_chatgpt_get_request(CHATGPT_ACCOUNTS_CHECK_API, access_token, chatgpt_account_id)
.await?;

// If Cloudflare temporarily challenged with 403, wait briefly and retry once.
if response.status() == StatusCode::FORBIDDEN {
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
if let Ok(retry_resp) =
send_chatgpt_get_request(CHATGPT_ACCOUNTS_CHECK_API, access_token, chatgpt_account_id)
.await
{
response = retry_resp;
}
}

let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
Expand Down Expand Up @@ -141,7 +152,7 @@ async fn get_usage_with_chatgpt_auth(account: &StoredAccount) -> Result<UsageInf
let fresh_account = ensure_chatgpt_tokens_fresh(account).await?;
let (access_token, chatgpt_account_id) = extract_chatgpt_auth(&fresh_account)?;

let response = send_chatgpt_usage_request(access_token, chatgpt_account_id).await?;
let mut response = send_chatgpt_usage_request(access_token, chatgpt_account_id).await?;

// 401 means the token is genuinely expired — refresh and retry once.
// 403 is a Cloudflare challenge or permissions error; refreshing the token
Expand All @@ -162,6 +173,19 @@ async fn get_usage_with_chatgpt_auth(account: &StoredAccount) -> Result<UsageInf
.await;
}

// If Cloudflare temporarily challenged with 403, wait briefly and retry once
// without refreshing or burning the token.
if response.status() == StatusCode::FORBIDDEN {
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
if let Ok(retry_resp) = send_chatgpt_usage_request(access_token, chatgpt_account_id).await {
if retry_resp.status().is_success() {
return parse_usage_response(&fresh_account.id, &fresh_account.name, retry_resp)
.await;
}
response = retry_resp;
}
}

parse_usage_response(&fresh_account.id, &fresh_account.name, response).await
}

Expand All @@ -173,10 +197,12 @@ async fn parse_usage_response(
let status = response.status();

if !status.is_success() {
return Ok(UsageInfo::error(
account_id.to_string(),
format!("API error: {status}"),
));
let error_message = if status == StatusCode::FORBIDDEN {
"Rate limited by Cloudflare (403). Try again in a moment.".to_string()
} else {
format!("API error: {status}")
};
return Ok(UsageInfo::error(account_id.to_string(), error_message));
}

let body_text = response
Expand Down Expand Up @@ -539,7 +565,7 @@ fn extract_credits(credits: Option<CreditStatusDetails>) -> Option<CreditStatusD

/// Refresh all account usage
pub async fn refresh_all_usage(accounts: &[StoredAccount]) -> Vec<UsageInfo> {
let concurrency = accounts.len().min(10).max(1);
let concurrency = accounts.len().min(2).max(1);
let results: Vec<UsageInfo> = stream::iter(accounts.iter().cloned())
.map(|account| async move {
match get_account_usage(&account).await {
Expand Down
22 changes: 20 additions & 2 deletions src-tauri/src/commands/account_stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ use crate::types::{AuthData, AuthMode, StoredAccount};
const CHATGPT_PROFILE_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/profiles/me";
const CHATGPT_RESET_CREDITS_URL: &str =
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
const CODEX_USER_AGENT: &str = "codex-cli/1.0.0";
const CHATGPT_ORIGIN: &str = "https://chatgpt.com";
const BROWSER_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/136.0.0.0 Safari/537.36";

#[derive(Debug, Clone, Serialize)]
pub struct AccountUsageStats {
Expand Down Expand Up @@ -404,12 +407,27 @@ fn build_chatgpt_headers(
access_token: &str,
chatgpt_account_id: Option<&str>,
) -> anyhow::Result<HeaderMap> {
use reqwest::header::{ACCEPT_LANGUAGE, ORIGIN, REFERER};

let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static(CODEX_USER_AGENT));
headers.insert(USER_AGENT, HeaderValue::from_static(BROWSER_USER_AGENT));
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {access_token}"))?,
);
headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9"));
headers.insert(ORIGIN, HeaderValue::from_static(CHATGPT_ORIGIN));
headers.insert(REFERER, HeaderValue::from_static(CHATGPT_ORIGIN));

if let Ok(name) = HeaderName::from_bytes(b"sec-fetch-dest") {
headers.insert(name, HeaderValue::from_static("empty"));
}
if let Ok(name) = HeaderName::from_bytes(b"sec-fetch-mode") {
headers.insert(name, HeaderValue::from_static("cors"));
}
if let Ok(name) = HeaderName::from_bytes(b"sec-fetch-site") {
headers.insert(name, HeaderValue::from_static("same-origin"));
}

if let Some(account_id) = chatgpt_account_id {
headers.insert(
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/commands/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ pub async fn warmup_account(account_id: String) -> Result<(), String> {
pub async fn warmup_all_accounts() -> Result<WarmupSummary, String> {
let store = load_accounts().map_err(|e| e.to_string())?;
let total_accounts = store.accounts.len();
let concurrency = total_accounts.min(10).max(1);
let concurrency = total_accounts.min(2).max(1);

let results: Vec<(String, bool)> = stream::iter(store.accounts.into_iter())
.map(|account| async move {
Expand Down
39 changes: 23 additions & 16 deletions src/hooks/useAccounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export function useAccounts() {
const [error, setError] = useState<string | null>(null);
const accountsRef = useRef<AccountWithUsage[]>([]);
const metadataRefreshInFlightRef = useRef(new Set<string>());
const maxConcurrentUsageRequests = 10;
const maxConcurrentUsageRequests = 2;
const usageRequestDelayMs = 150;

useEffect(() => {
accountsRef.current = accounts;
Expand Down Expand Up @@ -48,7 +49,8 @@ export function useAccounts() {
async <T,>(
items: T[],
worker: (item: T) => Promise<void>,
concurrency: number
concurrency: number,
delayMs = 0
) => {
if (items.length === 0) return;
const limit = Math.min(Math.max(concurrency, 1), items.length);
Expand All @@ -57,6 +59,9 @@ export function useAccounts() {
while (true) {
const current = index++;
if (current >= items.length) return;
if (delayMs > 0 && current > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
await worker(items[current]);
}
});
Expand Down Expand Up @@ -134,10 +139,11 @@ export function useAccounts() {
metadataRefreshInFlightRef.current.delete(account.id);
}
},
maxConcurrentUsageRequests
1,
250
);
},
[maxConcurrentUsageRequests, runWithConcurrency]
[runWithConcurrency]
);

const refreshUsage = useCallback(
Expand All @@ -151,12 +157,7 @@ export function useAccounts() {
return;
}

// Explicit refreshes include metadata, but run it beside usage so a
// slow accounts endpoint never delays healthy rate-limit updates.
const metadataPromise = options?.refreshMetadata
? refreshMetadata(list)
: Promise.resolve();

const shouldRefreshMetadata = Boolean(options?.refreshMetadata);
const accountIds = list.map((account) => account.id);
const accountIdSet = new Set(accountIds);
const usageResults = new Map<string, UsageInfo>();
Expand Down Expand Up @@ -186,7 +187,8 @@ export function useAccounts() {
);
}
},
maxConcurrentUsageRequests
maxConcurrentUsageRequests,
usageRequestDelayMs
);

setAccounts((prev) =>
Expand All @@ -202,7 +204,12 @@ export function useAccounts() {
);

reportUsageToTray(Array.from(usageResults.values()));
await metadataPromise;

// Run metadata refresh sequentially after usage updates,
// so we don't bombard Cloudflare with parallel requests across endpoints.
if (shouldRefreshMetadata) {
await refreshMetadata(list);
}
} catch (err) {
console.error("Failed to refresh usage:", err);
throw err;
Expand All @@ -214,6 +221,7 @@ export function useAccounts() {
refreshMetadata,
reportUsageToTray,
runWithConcurrency,
usageRequestDelayMs,
]
);

Expand Down Expand Up @@ -437,10 +445,9 @@ export function useAccounts() {
}, []);

useEffect(() => {
loadAccounts().then((accountList) => {
void refreshUsage(accountList);
// Populate live expiry immediately. The native background process keeps
// its cache current while a desktop webview is hidden or suspended.
loadAccounts().then(async (accountList) => {
await refreshUsage(accountList);
// Populate live expiry sequentially after usage requests complete.
void refreshMetadata(accountList);
});

Expand Down
Loading