Skip to content
Merged
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
24 changes: 23 additions & 1 deletion apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
Expand Down
39 changes: 39 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
89 changes: 66 additions & 23 deletions rust/src/providers/claude/oauth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
58 changes: 53 additions & 5 deletions rust/src/providers/claude/scoped_weekly.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,7 @@ pub(super) fn scoped_weekly_windows(limits: &[ScopedWeeklyLimit]) -> Vec<NamedRa
if slug.is_empty() || !seen.insert(slug.clone()) {
return None;
}
let resets_at = limit
.resets_at
.as_deref()
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.with_timezone(&Utc));
let resets_at = limit_resets_at(limit);
Some(NamedRateWindow::new(
format!("claude-weekly-scoped-{slug}"),
format!("{title} only"),
Expand All @@ -68,6 +64,38 @@ pub(super) fn scoped_weekly_windows(limits: &[ScopedWeeklyLimit]) -> Vec<NamedRa
.collect()
}

/// All-models weekly window from `limits[]` (`kind == "weekly_all"`).
///
/// Prefer this over legacy `seven_day.utilization` when Anthropic migrates
/// weekly totals into the limits array (avoids phantom 100% from stale fields).
pub(super) fn weekly_all_window(limits: &[ScopedWeeklyLimit]) -> Option<RateWindow> {
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<DateTime<Utc>> {
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::*;
Expand Down Expand Up @@ -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<ScopedWeeklyLimit> = 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);
}
}
17 changes: 10 additions & 7 deletions rust/src/providers/claude/web_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading