diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 799f0dec24..f813d8874f 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -33,7 +33,7 @@ pub(crate) fn build_fetch_context( let has_kimi_code_api_key = id == ProviderId::Kimi && api_key.as_deref().is_some_and(|key| !key.trim().is_empty()); - let (source_mode, cookie_header) = if id.cookie_domain().is_none() { + let (mut source_mode, mut cookie_header) = if id.cookie_domain().is_none() { let source_mode = if active_token_env.is_some() { SourceMode::OAuth } else { @@ -83,6 +83,28 @@ pub(crate) fn build_fetch_context( } }; + // Cookie-web providers (Cursor, OpenCode, …) reject SourceMode::Cli. The shell + // historically mapped "manual + no cookie" to Cli, which surfaces as + // "Source mode 'Cli' not supported". Remap to Web and try browser cookies + // unless the user explicitly disabled cookies ("off"). + if source_mode == SourceMode::Cli + && cookie_source != "off" + && !instantiate_provider(id).supports_cli() + { + if cookie_header + .as_deref() + .map(str::trim) + .is_none_or(|s| s.is_empty()) + { + cookie_header = provider_cookie_domain(id, settings).and_then(|domain| { + codexbar::browser::cookies::get_cookie_header(domain) + .ok() + .filter(|h| !h.is_empty()) + }); + } + source_mode = SourceMode::Web; + } + let workspace_id = settings.workspace_id(id).trim().to_string(); let api_region = settings.api_region(id).trim().to_string(); let gateway_url = (id == ProviderId::Wayfinder && !settings.gateway_url(id).is_empty()) diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index 2c9f25eb89..ae7588da53 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -293,10 +293,49 @@ fn fetch_context_defaults_to_manual_cookies_without_browser_import() { &token_accounts, ); + // Cursor does not support Cli; empty manual cookie remaps to Web (browser attempt). + assert_eq!(ctx.source_mode, SourceMode::Web); +} + +#[test] +fn fetch_context_cursor_cookie_off_stays_cli() { + let mut settings = Settings::default(); + settings.set_cookie_source(ProviderId::Cursor, "off"); + let cookies = ManualCookies::default(); + let api_keys = ApiKeys::default(); + let token_accounts = HashMap::new(); + + let ctx = super::build_fetch_context( + ProviderId::Cursor, + &settings, + &cookies, + &api_keys, + &token_accounts, + ); + + // Explicit cookie-off keeps Cli (no browser scrape). assert_eq!(ctx.source_mode, SourceMode::Cli); assert!(ctx.manual_cookie_header.is_none()); } +#[test] +fn fetch_context_opencode_empty_manual_remaps_to_web() { + let settings = Settings::default(); + let cookies = ManualCookies::default(); + let api_keys = ApiKeys::default(); + let token_accounts = HashMap::new(); + + let ctx = super::build_fetch_context( + ProviderId::OpenCode, + &settings, + &cookies, + &api_keys, + &token_accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); +} + #[test] fn fetch_context_claude_uses_oauth_without_manual_cookie() { let settings = Settings::default(); diff --git a/rust/src/providers/claude/oauth/mod.rs b/rust/src/providers/claude/oauth/mod.rs index 5d35a6742e..b774c6c64b 100644 --- a/rust/src/providers/claude/oauth/mod.rs +++ b/rust/src/providers/claude/oauth/mod.rs @@ -362,12 +362,16 @@ impl ClaudeOAuthFetcher { let mut usage = UsageSnapshot::new(primary); - // Secondary: 7-day window - if let Some(weekly) = response - .seven_day - .as_ref() - .and_then(|w| Self::to_rate_window(w, Some(10080))) - { + // Secondary: prefer limits[] weekly_all over legacy seven_day (avoids + // phantom 100% when Anthropic leaves seven_day.utilization stale). + if let Some(weekly) = super::scoped_weekly::weekly_all_window(&response.limits).or_else( + || { + response + .seven_day + .as_ref() + .and_then(|w| Self::to_rate_window(w, Some(10080))) + }, + ) { usage = usage.with_secondary(weekly); } @@ -386,26 +390,18 @@ impl ClaudeOAuthFetcher { usage = usage.with_model_specific(sonnet); } - let extra_windows = [( - "claude-routines", - "Daily Routines", - response - .seven_day_routines - .as_ref() - .and_then(|w| Self::to_rate_window(w, Some(10080))), - )]; - for (id, title, window) in extra_windows { - if let Some(window) = window { - usage - .extra_rate_windows - .push(NamedRateWindow::new(id, title, window)); - } + if let Some(window) = response + .seven_day_routines + .as_ref() + .and_then(|w| Self::to_rate_window(w, Some(10080))) + { usage .extra_rate_windows - .extend(super::scoped_weekly::scoped_weekly_windows( - &response.limits, - )); + .push(NamedRateWindow::new("claude-routines", "Daily Routines", window)); } + usage + .extra_rate_windows + .extend(super::scoped_weekly::scoped_weekly_windows(&response.limits)); // Login method from rate limit tier or default if let Some(ref tier) = credentials.rate_limit_tier { @@ -540,6 +536,53 @@ mod tests { assert_eq!(scoped.window.used_percent, 7.0); } + #[test] + fn weekly_all_limit_wins_over_stale_seven_day_utilization() { + let response: OAuthUsageResponse = serde_json::from_str( + r#"{ + "five_hour": {"utilization": 8.0, "resets_at": "2026-07-20T04:29:59Z"}, + "seven_day": {"utilization": 1.0, "resets_at": "2026-07-26T22:59:59Z"}, + "limits": [ + { + "kind": "weekly_all", + "group": "weekly", + "percent": 1, + "resets_at": "2026-07-26T22:59:59Z" + }, + { + "kind": "weekly_scoped", + "group": "weekly", + "percent": 2, + "resets_at": "2026-07-26T22:59:59Z", + "scope": {"model": {"display_name": "Fable"}} + } + ] + }"#, + ) + .expect("oauth body with weekly_all"); + + let credentials = ClaudeOAuthCredentials { + access_token: "token".to_string(), + refresh_token: None, + expires_at: None, + scopes: vec![], + rate_limit_tier: Some("default_claude_max_5x".to_string()), + }; + let usage = ClaudeOAuthFetcher::new().build_usage_snapshot(&response, &credentials); + + assert!((usage.primary.used_percent - 8.0).abs() < f64::EPSILON); + // seven_day.utilization 1.0 would normalize to 100%; weekly_all wins. + assert!((usage.secondary.expect("weekly").used_percent - 1.0).abs() < f64::EPSILON); + assert_eq!( + usage + .extra_rate_windows + .iter() + .filter(|w| w.id.starts_with("claude-weekly-scoped-")) + .count(), + 1 + ); + } + #[test] fn parses_retry_after_seconds() { let header = HeaderValue::from_static("17"); diff --git a/rust/src/providers/claude/scoped_weekly.rs b/rust/src/providers/claude/scoped_weekly.rs index 0a9a023e83..7438a2ab11 100644 --- a/rust/src/providers/claude/scoped_weekly.rs +++ b/rust/src/providers/claude/scoped_weekly.rs @@ -54,11 +54,7 @@ pub(super) fn scoped_weekly_windows(limits: &[ScopedWeeklyLimit]) -> Vec Vec Option { + limits.iter().find_map(|limit| { + let kind = limit.kind.as_deref()?; + if !matches!(kind, "weekly_all" | "all_models" | "weekly_models") { + return None; + } + if limit.group.as_deref().is_some_and(|g| g != "weekly") { + return None; + } + let percent = limit.percent.filter(|value| value.is_finite())?; + let resets_at = limit_resets_at(limit); + Some(RateWindow::with_details( + percent.clamp(0.0, 100.0), + Some(7 * 24 * 60), + resets_at, + None, + )) + }) +} + +fn limit_resets_at(limit: &ScopedWeeklyLimit) -> Option> { + limit + .resets_at + .as_deref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.with_timezone(&Utc)) +} + #[cfg(test)] mod tests { use super::*; @@ -102,4 +130,24 @@ mod tests { assert!(scoped_weekly_windows(&limits).is_empty()); } + + #[test] + fn weekly_all_prefers_limits_percent_over_stale_seven_day() { + let limits: Vec = serde_json::from_str( + r#"[ + {"kind":"weekly_all","group":"weekly","percent":1,"resets_at":"2026-07-26T22:59:59Z"}, + {"kind":"weekly_scoped","group":"weekly","percent":2,"scope":{"model":{"display_name":"Fable"}}} + ]"#, + ) + .unwrap(); + + let weekly = weekly_all_window(&limits).expect("weekly_all"); + assert!((weekly.used_percent - 1.0).abs() < f64::EPSILON); + assert_eq!(weekly.window_minutes, Some(7 * 24 * 60)); + assert!(weekly.resets_at.is_some()); + + let scoped = scoped_weekly_windows(&limits); + assert_eq!(scoped.len(), 1); + assert!((scoped[0].window.used_percent - 2.0).abs() < f64::EPSILON); + } } diff --git a/rust/src/providers/claude/web_api.rs b/rust/src/providers/claude/web_api.rs index 5fb55cee6a..d4fa736b3e 100755 --- a/rust/src/providers/claude/web_api.rs +++ b/rust/src/providers/claude/web_api.rs @@ -319,10 +319,13 @@ impl ClaudeWebApiFetcher { .map(|w| self.to_rate_window(w, Some(300))) // 5 hours = 300 minutes .unwrap_or_else(synthetic_no_session_primary); - let secondary = usage - .seven_day - .as_ref() - .map(|w| self.to_rate_window(w, Some(10080))); // 7 days = 10080 minutes + // Prefer limits[] weekly_all over legacy seven_day (same as OAuth path). + let secondary = super::scoped_weekly::weekly_all_window(&usage.limits).or_else(|| { + usage + .seven_day + .as_ref() + .map(|w| self.to_rate_window(w, Some(10080))) // 7 days = 10080 minutes + }); let model_specific = usage .seven_day_opus @@ -362,10 +365,10 @@ impl ClaudeWebApiFetcher { .extra_rate_windows .push(NamedRateWindow::new(id, title, window)); } - snapshot - .extra_rate_windows - .extend(super::scoped_weekly::scoped_weekly_windows(&usage.limits)); } + snapshot + .extra_rate_windows + .extend(super::scoped_weekly::scoped_weekly_windows(&usage.limits)); if let Some(ref acc) = account { if let Some(ref email) = acc.email_address { diff --git a/rust/src/providers/opencode/mod.rs b/rust/src/providers/opencode/mod.rs index ddbeba4e15..a2de72a17f 100755 --- a/rust/src/providers/opencode/mod.rs +++ b/rust/src/providers/opencode/mod.rs @@ -187,10 +187,27 @@ impl OpenCodeProvider { // Fall back to regex-based parsing. Require at least one window — // plans may omit rolling or weekly without being unparseable. - let rolling = self.extract_usage_regex(text, "rollingUsage").ok(); - let weekly = self.extract_usage_regex(text, "weeklyUsage").ok(); + let rolling = self + .extract_usage_regex(text, "rollingUsage") + .or_else(|_| self.extract_usage_regex(text, "fiveHourUsage")) + .or_else(|_| self.extract_usage_regex(text, "sessionUsage")) + .ok(); + let weekly = self + .extract_usage_regex(text, "weeklyUsage") + .or_else(|_| self.extract_usage_regex(text, "weekUsage")) + .or_else(|_| self.extract_usage_regex(text, "sevenDayUsage")) + .ok(); self.snapshot_from_windows(rolling, weekly, now, self.extract_renewal_regex(text)) .ok_or_else(|| { + let has_usage_hint = text.contains("usagePercent") + || text.contains("usedPercent") + || text.contains("rolling") + || text.contains("weekly"); + tracing::debug!( + body_len = text.len(), + has_usage_hint, + "OpenCode subscription body missing rolling/weekly usage windows" + ); ProviderError::Parse("Missing usage percent (rolling or weekly)".into()) }) } @@ -243,12 +260,45 @@ impl OpenCodeProvider { /// Parse usage from JSON response fn parse_usage_json(&self, json: &Value, now: DateTime) -> Option { - let renews_at = self.find_datetime(json, &["renewAt", "renew_at"]); + // Prefer nested billing/subscription/data roots when present. + for root_key in ["data", "subscription", "billing", "result", "payload"] { + if let Some(nested) = json.get(root_key) + && let Some(snapshot) = self.parse_usage_json(nested, now) + { + return Some(snapshot); + } + } - let rolling = - self.find_usage_window(json, &["rollingUsage", "rolling", "rolling_usage"]); - let weekly = - self.find_usage_window(json, &["weeklyUsage", "weekly", "weekly_usage"]); + let renews_at = self.find_datetime(json, &["renewAt", "renew_at", "renewsAt"]); + + let rolling = self.find_usage_window( + json, + &[ + "rollingUsage", + "rolling", + "rolling_usage", + "fiveHourUsage", + "five_hour", + "fiveHour", + "sessionUsage", + "session", + "rateLimit5h", + "rate_limit_5h", + ], + ); + let weekly = self.find_usage_window( + json, + &[ + "weeklyUsage", + "weekly", + "weekly_usage", + "weekUsage", + "sevenDayUsage", + "seven_day", + "rateLimitWeekly", + "rate_limit_weekly", + ], + ); self.snapshot_from_windows(rolling, weekly, now, renews_at) } @@ -263,9 +313,16 @@ impl OpenCodeProvider { } } - // Try nested search + // Try nested search (depth-limited by recursion over objects only). if let Some(obj) = json.as_object() { - for (_, value) in obj { + for (child_key, value) in obj { + // Skip huge unrelated trees that often appear next to usage. + if matches!( + child_key.as_str(), + "history" | "invoices" | "members" | "logs" | "events" + ) { + continue; + } if let Some(window) = self.find_usage_window(value, keys) { return Some(window); } @@ -277,6 +334,11 @@ impl OpenCodeProvider { /// Parse a usage window object fn parse_window(&self, obj: &Value) -> Option<(f64, i64)> { + // Bare number: treat as percent used (0–1 fractions scaled). + if let Some(n) = obj.as_f64() { + let percent = if n <= 1.0 { n * 100.0 } else { n }; + return Some((percent.clamp(0.0, 100.0), 0)); + } let percent = Self::window_percent(obj)?; let reset_sec = Self::window_reset_seconds(obj).unwrap_or(0); Some((percent.clamp(0.0, 100.0), reset_sec.max(0))) @@ -288,12 +350,14 @@ impl OpenCodeProvider { "usedPercent", "percentUsed", "percent", + "pct", "usage_percent", "used_percent", "utilization", "utilizationPercent", "utilization_percent", "usage", + "value", ]; Self::first_f64(obj, &percent_keys) @@ -620,4 +684,39 @@ mod tests { let snap = provider.parse_subscription(text).expect("snapshot"); assert!((snap.primary.used_percent - 55.0).abs() < f64::EPSILON); } + + #[test] + fn parses_nested_data_subscription_shape() { + let provider = OpenCodeProvider::new(); + let now = DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); + let payload = serde_json::json!({ + "data": { + "subscription": { + "rollingUsage": { "usedPercent": 18.5, "resetInSeconds": 1200 }, + "weeklyUsage": { "percentUsed": 42, "resetsInSec": 86400 } + } + } + }); + let snap = provider + .parse_usage_json(&payload, now) + .expect("nested snapshot"); + assert!((snap.primary.used_percent - 18.5).abs() < f64::EPSILON); + assert!((snap.secondary.as_ref().unwrap().used_percent - 42.0).abs() < f64::EPSILON); + } + + #[test] + fn parses_five_hour_and_week_aliases() { + let provider = OpenCodeProvider::new(); + let now = DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); + let payload = serde_json::json!({ + "fiveHourUsage": { "pct": 11, "resetInSec": 300 }, + "weekUsage": { "value": 0.25, "resetInSec": 7200 } + }); + let snap = provider + .parse_usage_json(&payload, now) + .expect("alias snapshot"); + assert!((snap.primary.used_percent - 11.0).abs() < f64::EPSILON); + // 0.25 fraction scales to 25% + assert!((snap.secondary.as_ref().unwrap().used_percent - 25.0).abs() < f64::EPSILON); + } }