diff --git a/CHANGELOG.md b/CHANGELOG.md index 369950946bd..d25737b0acc 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## [Windows] Unreleased +- **Claude**: the OAuth account email no longer delays a usage refresh. The profile lookup runs in the background, once per access token that passes the expiry and `user:profile` scope checks, with a 2-second budget, and later refreshes reuse its result. A failed lookup is not retried until the token changes or CodexBar restarts (previously every 15 minutes), and the first refresh after launch or a token change may show no email until the next refresh (upstream v0.76.0, #4538). - **Claude**: OAuth usage now shows saved limit-reset credits, as the Web source already did. The usage request opts in with `cedar_ember=1` and identifies as the Claude CLI (`claude-cli/ (external, cli)`); when Claude rejects that optional query with HTTP 400 or a 403 that does not name the missing `user:profile` scope, it retries once without it, as `claude-code/`, so quota and Extra usage still appear. The version is read from `claude --version`, or 2.1.0 when it cannot be read. Missing-scope 403, 401 and 429 keep their normal handling. The cached usage body keeps reset counts and dates but never the grant identifiers (upstream 0.72.0, #3895, #4232). - Usage forecasts: an older session reset time that later moved backward no longer hides the estimated session quotas left and windows until reset for Codex and Claude. The estimator starts a new history segment at the correction and still needs three recent samples (upstream #4540). - OpenCode Go: include OpenCode v2 session history in local cost and token totals, without counting migrated messages twice (upstream v0.77.0 #4526). diff --git a/rust/src/providers/claude/oauth/mod.rs b/rust/src/providers/claude/oauth/mod.rs index 5b054408c89..722f215a367 100644 --- a/rust/src/providers/claude/oauth/mod.rs +++ b/rust/src/providers/claude/oauth/mod.rs @@ -633,17 +633,20 @@ impl ClaudeOAuthFetcher { credentials: ClaudeOAuthCredentials, gate_key: &str, ) -> Result { + let fingerprint = usage_gate::token_fingerprint(&credentials.access_token); + // Upstream validates the token before its profile lookup, so an expired + // or scope-less token sends no /profile request. + Self::check_usage_credentials(&credentials)?; + // Started before the usage request so a quick profile answer can still + // label this refresh; the usage request never waits for it. + let _ = profile::display_email(&self.client, &credentials.access_token, &fingerprint); let usage_response = self.fetch_usage(&credentials, gate_key).await?; let mut usage = self.build_usage_snapshot(&usage_response, &credentials); let spend_cost = apply_spend_only_extra_usage(&mut usage, &usage_response); - // Header email only; a profile failure leaves the usage result intact. + // Header email only; a missing or failed profile leaves usage intact. if usage.account_email.is_none() - && let Some(email) = profile::fetch_email( - &self.client, - &credentials.access_token, - &usage_gate::token_fingerprint(&credentials.access_token), - ) - .await + && let Some(email) = + profile::display_email(&self.client, &credentials.access_token, &fingerprint) { usage = usage.with_email(email); } @@ -784,12 +787,9 @@ impl ClaudeOAuthFetcher { } } - /// Fetch usage data using OAuth credentials - pub async fn fetch_usage( - &self, - credentials: &ClaudeOAuthCredentials, - gate_key: &str, - ) -> Result { + /// Expiry and `user:profile` scope checks that run before any request for + /// the token, including the profile lookup. + fn check_usage_credentials(credentials: &ClaudeOAuthCredentials) -> Result<(), ProviderError> { if credentials.is_expired() { return Err(ProviderError::OAuthExpired( "OAuth token expired. Run `claude` to refresh.".to_string(), @@ -804,6 +804,16 @@ impl ClaudeOAuthFetcher { Self::scope_recovery_message() ))); } + Ok(()) + } + + /// Fetch usage data using OAuth credentials + pub async fn fetch_usage( + &self, + credentials: &ClaudeOAuthCredentials, + gate_key: &str, + ) -> Result { + Self::check_usage_credentials(credentials)?; if let Some(result) = Self::gate_precheck(self.usage_gate, usage_gate::now_ms(), gate_key) { return result; diff --git a/rust/src/providers/claude/oauth/profile.rs b/rust/src/providers/claude/oauth/profile.rs index 1075e2a1acb..adec2e016a4 100644 --- a/rust/src/providers/claude/oauth/profile.rs +++ b/rust/src/providers/claude/oauth/profile.rs @@ -1,73 +1,116 @@ //! Best-effort Claude OAuth profile lookup for the card header email. //! -//! Upstream reads the account email from `GET /api/oauth/profile` next to the -//! usage call. The email is display metadata only: a failed, slow or -//! malformed profile response never fails the usage fetch, and the result is -//! remembered per token fingerprint so a refresh loop does not re-ask. +//! Upstream (#4538) reads the account email from `GET /api/oauth/profile` +//! off the usage refresh's critical path: one background lookup per access +//! token, bounded to [`LOOKUP_BUDGET`]. The usage refresh never waits for it, +//! so a refresh that starts the lookup may publish without an email; later +//! refreshes of the same token reuse the answer. Both outcomes are cached in +//! memory: a found email for the life of the process, a failure until the +//! token changes or CodexBar restarts. The email is display metadata for the +//! Claude card only. use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; -use std::time::{Duration, Instant}; +use std::time::Duration; use reqwest::Client; -pub(super) const PROFILE_URL: &str = "https://api.anthropic.com/api/oauth/profile"; -const PROFILE_TIMEOUT: Duration = Duration::from_secs(5); -/// A failed lookup is retried after this long; a found email is kept for the -/// life of the token. -const FAILURE_RETRY: Duration = Duration::from_secs(15 * 60); +const PROFILE_URL: &str = "https://api.anthropic.com/api/oauth/profile"; +/// Whole-lookup budget: connect, response and body. +const LOOKUP_BUDGET: Duration = Duration::from_secs(2); enum CachedProfile { + Pending, Found(String), - Failed(Instant), + Failed, } +/// Keyed by token fingerprint, so a new token never borrows another token's email. static PROFILE_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); -/// The cached answer for `fingerprint`: `Some(Some(email))` when known, -/// `Some(None)` while a recent failure is cooling down, `None` to ask again. -fn cached(fingerprint: &str, now: Instant) -> Option> { - let cache = PROFILE_CACHE.lock().unwrap_or_else(|e| e.into_inner()); - match cache.get(fingerprint)? { - CachedProfile::Found(email) => Some(Some(email.clone())), - CachedProfile::Failed(at) if now.duration_since(*at) < FAILURE_RETRY => Some(None), - CachedProfile::Failed(_) => None, - } +// Test-only profile URL, so fetch-level tests can observe lookups on a mock +// server. Thread-local because each test runs on its own thread. +#[cfg(test)] +thread_local! { + static TEST_PROFILE_URL: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; } -fn remember(fingerprint: &str, email: Option<&str>, now: Instant) { - let entry = match email { - Some(email) => CachedProfile::Found(email.to_string()), - None => CachedProfile::Failed(now), - }; - PROFILE_CACHE - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(fingerprint.to_string(), entry); +fn profile_url() -> String { + #[cfg(test)] + { + if let Some(url) = TEST_PROFILE_URL.with(|url| url.borrow().clone()) { + return url; + } + } + PROFILE_URL.to_string() } -/// The account email for `access_token`, or `None` on any failure. -pub(super) async fn fetch_email( +/// The known email for `access_token`, without waiting. The first call for a +/// token starts the background lookup and returns `None`, as do later calls +/// while it is pending or after it failed. +pub(super) fn display_email( client: &Client, access_token: &str, fingerprint: &str, ) -> Option { - if let Some(answer) = cached(fingerprint, Instant::now()) { - return answer; + lookup( + client, + &profile_url(), + access_token, + fingerprint, + LOOKUP_BUDGET, + ) + .0 +} + +/// [`display_email`] against `url`, also returning the started lookup task. +fn lookup( + client: &Client, + url: &str, + access_token: &str, + fingerprint: &str, + budget: Duration, +) -> (Option, Option>) { + let mut cache = PROFILE_CACHE.lock().unwrap_or_else(|e| e.into_inner()); + match cache.get(fingerprint) { + Some(CachedProfile::Found(email)) => return (Some(email.clone()), None), + Some(CachedProfile::Pending | CachedProfile::Failed) => return (None, None), + None => {} } - let email = request_email(client, access_token).await; - remember(fingerprint, email.as_deref(), Instant::now()); - email + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + return (None, None); + }; + cache.insert(fingerprint.to_string(), CachedProfile::Pending); + drop(cache); + + let client = client.clone(); + let url = url.to_string(); + let access_token = access_token.to_string(); + let fingerprint = fingerprint.to_string(); + let task = runtime.spawn(async move { + let email = tokio::time::timeout(budget, request_email(&client, &url, &access_token)) + .await + .unwrap_or_else(|_| { + tracing::debug!("Claude OAuth profile lookup timed out"); + None + }); + let entry = email.map_or(CachedProfile::Failed, CachedProfile::Found); + PROFILE_CACHE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(fingerprint, entry); + }); + (None, Some(task)) } -async fn request_email(client: &Client, access_token: &str) -> Option { +async fn request_email(client: &Client, url: &str, access_token: &str) -> Option { let response = client - .get(PROFILE_URL) + .get(url) .header("Authorization", format!("Bearer {access_token}")) .header("Accept", "application/json") .header("anthropic-beta", "oauth-2025-04-20") - .timeout(PROFILE_TIMEOUT) .send() .await; let response = match response { @@ -156,18 +199,225 @@ mod tests { assert!(parse_email(r#"{"organization":{"name":"Parity Labs"}}"#).is_none()); } + // Wire tests mirror upstream `ClaudeOAuthProfileIdentityTests` (#4538). + // Every test uses its own tokens: the cache is process-wide. + + use mockito::{Matcher, Mock, Server, ServerGuard}; + use std::time::Instant; + + const TEST_BUDGET: Duration = Duration::from_secs(2); + + fn fingerprint(token: &str) -> String { + super::super::usage_gate::token_fingerprint(token) + } + + async fn profile_mock( + server: &mut ServerGuard, + token: &str, + status: usize, + calls: usize, + ) -> Mock { + server + .mock("GET", "/api/oauth/profile") + .match_header("authorization", format!("Bearer {token}").as_str()) + .match_header("anthropic-beta", "oauth-2025-04-20") + .match_query(Matcher::Any) + .with_status(status) + .with_body(format!( + r#"{{"account":{{"email_address":"{token}@example.com"}}}}"# + )) + .expect(calls) + .create_async() + .await + } + + /// One "refresh": read the cache, starting a lookup when the token is new. + async fn refresh(url: &str, token: &str, budget: Duration) -> Option { + let (email, task) = lookup(&Client::new(), url, token, &fingerprint(token), budget); + if let Some(task) = task { + task.await.unwrap(); + } + email + } + + #[tokio::test] + async fn first_refresh_starts_the_lookup_and_later_refreshes_reuse_its_email() { + let mut server = Server::new_async().await; + let token = "fixture-profile-found"; + let mock = profile_mock(&mut server, token, 200, 1).await; + let url = format!("{}/api/oauth/profile", server.url()); + + assert_eq!(refresh(&url, token, TEST_BUDGET).await, None); + for _ in 0..2 { + assert_eq!( + refresh(&url, token, TEST_BUDGET).await.as_deref(), + Some("fixture-profile-found@example.com") + ); + } + mock.assert_async().await; + } + + #[tokio::test] + async fn credential_changes_cannot_borrow_another_tokens_email() { + let mut server = Server::new_async().await; + let first = profile_mock(&mut server, "fixture-profile-first", 200, 1).await; + let second = profile_mock(&mut server, "fixture-profile-second", 200, 1).await; + let url = format!("{}/api/oauth/profile", server.url()); + + for token in [ + "fixture-profile-first", + "fixture-profile-second", + "fixture-profile-first", + ] { + refresh(&url, token, TEST_BUDGET).await; + assert_eq!( + refresh(&url, token, TEST_BUDGET).await, + Some(format!("{token}@example.com")) + ); + } + first.assert_async().await; + second.assert_async().await; + } + + #[tokio::test] + async fn failed_lookup_is_not_retried_for_the_same_token() { + let mut server = Server::new_async().await; + let token = "fixture-profile-failed"; + let mock = profile_mock(&mut server, token, 500, 1).await; + let url = format!("{}/api/oauth/profile", server.url()); + + assert_eq!(refresh(&url, token, TEST_BUDGET).await, None); + assert_eq!(refresh(&url, token, TEST_BUDGET).await, None); + assert_eq!(refresh(&url, token, TEST_BUDGET).await, None); + mock.assert_async().await; + } + + #[tokio::test] + async fn stalled_lookup_never_delays_the_caller_and_fails_soft_at_its_budget() { + // Accepts connections and never answers. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!( + "http://{}/api/oauth/profile", + listener.local_addr().unwrap() + ); + let hold = tokio::spawn(async move { + let mut held = Vec::new(); + while let Ok((socket, _)) = listener.accept().await { + held.push(socket); + } + }); + let token = "fixture-profile-stalled"; + + let started = Instant::now(); + let (email, task) = lookup( + &Client::new(), + &url, + token, + &fingerprint(token), + Duration::from_millis(200), + ); + assert_eq!(email, None); + assert!(started.elapsed() < Duration::from_millis(200)); + + task.expect("lookup started").await.unwrap(); + let (email, task) = lookup( + &Client::new(), + &url, + token, + &fingerprint(token), + Duration::from_millis(200), + ); + assert_eq!(email, None); + assert!(task.is_none(), "a timed-out token must not be retried"); + hold.abort(); + } + #[test] - fn cache_keeps_found_emails_and_cools_down_failures() { - let now = Instant::now(); - remember("fp-found-test", Some("parity.user@example.com"), now); - assert_eq!( - cached("fp-found-test", now + Duration::from_secs(86_400)), - Some(Some("parity.user@example.com".to_string())) + fn without_a_runtime_no_lookup_starts_and_nothing_is_cached() { + let token = "fixture-profile-no-runtime"; + let (email, task) = lookup( + &Client::new(), + "http://127.0.0.1:9/api/oauth/profile", + token, + &fingerprint(token), + TEST_BUDGET, ); + assert!(email.is_none() && task.is_none()); + assert!( + !PROFILE_CACHE + .lock() + .unwrap() + .contains_key(&fingerprint(token)) + ); + } + + #[tokio::test] + async fn expired_or_scope_less_tokens_send_no_profile_request() { + let mut server = Server::new_async().await; + let profile = server + .mock("GET", "/api/oauth/profile") + .with_status(200) + .with_body(PROFILE_FIXTURE) + .expect(0) + .create_async() + .await; + TEST_PROFILE_URL.with(|url| { + *url.borrow_mut() = Some(format!("{}/api/oauth/profile", server.url())); + }); + let expired = super::super::ClaudeOAuthCredentials { + access_token: "fixture-profile-expired".to_string(), + refresh_token: None, + expires_at: Some(chrono::Utc::now() - chrono::Duration::minutes(10)), + scopes: vec!["user:profile".to_string()], + rate_limit_tier: None, + }; + let scope_less = super::super::ClaudeOAuthCredentials { + access_token: "fixture-profile-scope-less".to_string(), + refresh_token: None, + expires_at: None, + scopes: vec!["user:inference".to_string()], + rate_limit_tier: None, + }; + let fetcher = super::super::ClaudeOAuthFetcher::new(); - remember("fp-failed-test", None, now); - assert_eq!(cached("fp-failed-test", now), Some(None)); - assert_eq!(cached("fp-failed-test", now + FAILURE_RETRY), None); - assert_eq!(cached("fp-unknown-test", now), None); + let expired_error = fetcher + .fetch_with_credentials(expired, "fixture-gate") + .await + .err() + .unwrap(); + let scope_error = fetcher + .fetch_with_credentials(scope_less, "fixture-gate") + .await + .err() + .unwrap(); + + assert!(matches!( + expired_error, + crate::core::ProviderError::OAuthExpired(_) + )); + assert!(matches!( + scope_error, + crate::core::ProviderError::OAuth(ref message) if message.contains("user:profile") + )); + // A lookup that had started would answer the mock within milliseconds. + tokio::time::sleep(Duration::from_millis(200)).await; + profile.assert_async().await; + } + + /// Control for the test above: the mock does see a /profile request when a + /// lookup runs for a valid token. + #[tokio::test] + async fn a_valid_token_reaches_the_profile_endpoint() { + let mut server = Server::new_async().await; + let token = "fixture-profile-valid"; + let mock = profile_mock(&mut server, token, 200, 1).await; + let url = format!("{}/api/oauth/profile", server.url()); + + assert_eq!( + refresh(&url, token, TEST_BUDGET).await, + None, + "the first refresh starts the lookup" + ); + mock.assert_async().await; } }