From 16587b74a38de9dc60d92fbdbb160675fc37b3d9 Mon Sep 17 00:00:00 2001 From: Fimeg Date: Wed, 12 Aug 2026 16:45:52 -0400 Subject: [PATCH] claude: hold every login at once, move when a window is spent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `credential_files` holds them all; a 429 with retry-after over a minute takes the next login and retries at once. Sticky — a swap abandons the cached prefix, so short bursts are waited out where the cache lives. --- src/bridge/claude_subscription.rs | 264 ++++++++++++++++++++++++++---- src/bridge/mod.rs | 1 + src/core/config.rs | 10 ++ src/ui/screens/settings/mod.rs | 1 + src/ui/settings/view.rs | 1 + 5 files changed, 247 insertions(+), 30 deletions(-) diff --git a/src/bridge/claude_subscription.rs b/src/bridge/claude_subscription.rs index 207c8ea..44c0944 100644 --- a/src/bridge/claude_subscription.rs +++ b/src/bridge/claude_subscription.rs @@ -50,6 +50,9 @@ const CC_ENTRYPOINT: &str = "cli"; const CC_PLATFORM: &str = "claude_code_cli"; const FALLBACK_CC_VERSION: &str = "2.1.196"; const TOKEN_REFRESH_BUFFER: Duration = Duration::from_secs(5 * 60); +/// Longest `retry-after` still worth waiting out on the account already holding +/// the cached prefix, instead of moving to another login. +const BURST_RETRY_CEILING_SECS: u64 = 60; const DEFAULT_MAX_TOKENS: u32 = 8192; /// Models that think by default cap thinking *plus* response text with /// `max_tokens`, so 8192 truncates mid-answer. @@ -108,7 +111,19 @@ pub struct ClaudeSubscriptionProvider { account_uuid: String, extra_metadata: Map, default_model: String, - token: Arc>, + accounts: Arc>, +} + +/// Every login this provider may speak as, and which one it is speaking as now. +/// +/// One subscription is one quota. A second login is the only thing that keeps +/// answering once the first is spent, and it has to be held *alongside* the +/// first — a token swapped in by hand arrives after the conversation has +/// already failed, and swapping back and forth throws away the account's cached +/// prefix each way. +struct Accounts { + tokens: Vec, + active: usize, } #[derive(Clone)] @@ -120,6 +135,20 @@ struct TokenState { source_stamp: Option, } +impl TokenState { + fn has_credential(&self) -> bool { + let live = |t: &Option| t.as_deref().is_some_and(|t| !t.is_empty()); + live(&self.access_token) || live(&self.refresh_token) + } + + fn label(&self) -> String { + match &self.source_path { + Some(p) => p.display().to_string(), + None => "".to_string(), + } + } +} + /// Identity of the credential file as of the read that produced a `TokenState`. #[derive(Clone, Copy, PartialEq, Eq, Debug)] struct FileStamp { @@ -138,6 +167,7 @@ impl ClaudeSubscriptionProvider { primary_model: &str, timeout_secs: u64, credential_file: Option<&str>, + credential_files: Option<&[String]>, cc_version: Option<&str>, account_uuid: Option<&str>, device_id: Option<&str>, @@ -155,20 +185,34 @@ impl ClaudeSubscriptionProvider { .build() .context("failed to build HTTP client")?; - let cred_path = resolve_credential_path(credential_file).context( - "no Claude Code credential file found (set credential_file or log in with `claude`)", - )?; - let token = load_credentials(&cred_path).with_context(|| { - format!( - "failed to read Claude Code credentials at {}", - cred_path.display() - ) - })?; - if token.access_token.is_none() && token.refresh_token.is_none() { + let cred_paths = resolve_credential_paths(credential_file, credential_files); + if cred_paths.is_empty() { anyhow::bail!( - "credential file has no access_token/refresh_token; log in with `claude` first" + "no Claude Code credential file found (set credential_file or log in with `claude`)" ); } + let mut tokens = Vec::new(); + for path in &cred_paths { + match load_credentials(path) { + Ok(token) if token.has_credential() => tokens.push(token), + Ok(_) => warn!("{} has no tokens; skipping", path.display()), + Err(e) => warn!("{} unreadable; skipping: {e}", path.display()), + } + } + let (cred_path, cred_id) = { + let token = tokens.first().ok_or_else(|| { + anyhow::anyhow!( + "no usable Claude Code login in {}; log in with `claude` first", + cred_paths + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", ") + ) + })?; + (token.label(), credential_fingerprint(token)) + }; + let spares = tokens.len() - 1; let device_id = match device_id.filter(|v| is_device_id(v)) { Some(id) => id.to_ascii_lowercase(), @@ -185,11 +229,8 @@ impl ClaudeSubscriptionProvider { .unwrap_or_default(); info!( - "🔐 Claude subscription provider initialized — model: {}, cc_version: {}, creds: {} (cred {})", - primary_model, - cc_version, - cred_path.display(), - credential_fingerprint(&token), + "🔐 Claude subscription provider initialized — model: {}, cc_version: {}, creds: {} (cred {}, {} spare login(s))", + primary_model, cc_version, cred_path, cred_id, spares, ); Ok(Self { @@ -202,7 +243,7 @@ impl ClaudeSubscriptionProvider { account_uuid, extra_metadata: extra, default_model: primary_model.to_string(), - token: Arc::new(Mutex::new(token)), + accounts: Arc::new(Mutex::new(Accounts { tokens, active: 0 })), }) } @@ -254,17 +295,19 @@ impl ClaudeSubscriptionProvider { Ok(serde_json::to_vec(&body)?) } - /// Return a usable access token, refreshing under the mutex if it is - /// missing or inside the proactive window. - async fn current_access_token(&self) -> Result { - let mut token = self.token.lock().await; - adopt_file_if_changed(&mut token); + /// Return a usable access token and the account it belongs to, refreshing + /// under the mutex if it is missing or inside the proactive window. + async fn current_access_token(&self) -> Result<(String, usize)> { + let mut accounts = self.accounts.lock().await; + let active = accounts.active; + let token = &mut accounts.tokens[active]; + adopt_file_if_changed(token); let access_empty = token.access_token.as_deref().is_none_or(str::is_empty); let expiring = token .expires_at_ms .is_none_or(|exp| exp <= now_ms() + TOKEN_REFRESH_BUFFER.as_millis() as u64); if access_empty || expiring { - if let Err(e) = refresh_token(&self.http, &self.user_agent, &mut token).await { + if let Err(e) = refresh_token(&self.http, &self.user_agent, token).await { // Refresh tokens are one-time-use, and this credential file is // shared with `claude` itself. Whichever process refreshes // first rotates the token out from under the other, so a @@ -273,7 +316,7 @@ impl ClaudeSubscriptionProvider { // revoked. Re-read the file before giving up: if it now holds a // newer, still-valid token, adopt it. Without this the provider // 400s on every turn until the service is restarted by hand. - match reread_credentials(&token) { + match reread_credentials(token) { Some(rotated) => { info!("claude token refresh failed ({e}); adopted rotated credentials"); *token = rotated; @@ -282,13 +325,48 @@ impl ClaudeSubscriptionProvider { } } } - token + let access = token .access_token .clone() .filter(|t| !t.is_empty()) .ok_or_else(|| { anyhow::anyhow!("no claude access_token available (refresh failed and none cached)") - }) + })?; + Ok((access, active)) + } + + /// Move off the account at `from`, which has nothing left to spend. + /// + /// Sticky by design: the next login is used until it too is exhausted, + /// rather than alternating. Switching accounts abandons a prompt cache the + /// transcript has already paid for — measured 2026-08-12 at ~125k cached + /// tokens a turn — so a swap has to be worth a full re-bill, and only an + /// exhausted window is. + async fn rotate_account(&self, from: usize) -> Option { + let mut accounts = self.accounts.lock().await; + let count = accounts.tokens.len(); + if count < 2 { + return None; + } + // Another in-flight request already moved us; ride along rather than + // stepping past a login nobody has tried yet. + if accounts.active != from { + return Some(accounts.active); + } + for step in 1..count { + let candidate = (from + step) % count; + adopt_file_if_changed(&mut accounts.tokens[candidate]); + if accounts.tokens[candidate].has_credential() { + info!( + "claude login {} is out of quota; continuing as {}", + accounts.tokens[from].label(), + accounts.tokens[candidate].label(), + ); + accounts.active = candidate; + return Some(candidate); + } + } + None } } @@ -303,7 +381,7 @@ impl LlmProvider for ClaudeSubscriptionProvider { } async fn list_models(&self) -> Result> { - let access_token = self.current_access_token().await?; + let (access_token, _) = self.current_access_token().await?; let resp = self .http .get(format!("{}/v1/models", self.upstream_base)) @@ -345,7 +423,7 @@ impl LlmProvider for ClaudeSubscriptionProvider { let body = self.shape_body(&model, &request)?; for attempt in 0..max_attempts { - let access_token = self.current_access_token().await?; + let (access_token, account) = self.current_access_token().await?; let resp = self .http .post(format!("{}/v1/messages", self.upstream_base)) @@ -429,7 +507,24 @@ impl LlmProvider for ClaudeSubscriptionProvider { return Ok((result, strain)); } + let retry_after = retry_after_secs(resp.headers()); let body_text = resp.text().await.unwrap_or_default(); + + // A spent quota window does not clear inside a retry loop — the only + // thing that answers is another login. Retry it at once: the wait is + // hours, not milliseconds. + if is_exhausted_window(status.as_u16(), retry_after) + && self.rotate_account(account).await.is_some() + { + strain.push(InferenceStrain::Transient { + attempt, + status: status.as_u16(), + model: model.clone(), + delay_ms: 0, + }); + continue; + } + match classify_status(status) { ErrorClass::Transient if attempt + 1 < max_attempts => { let delay = backoff(attempt); @@ -455,9 +550,10 @@ impl LlmProvider for ClaudeSubscriptionProvider { body: body_text.chars().take(300).collect(), }); anyhow::bail!( - "claude subscription returned {status} after {} attempt(s) on {model}{}: {}", + "claude subscription returned {status} after {} attempt(s) on {model}{}{}: {}", attempt + 1, status_hint(status.as_u16()), + reset_hint(retry_after), &body_text[..body_text.len().min(500)] ); } @@ -647,6 +743,37 @@ async fn fetch_account_uuid( // ── credentials file ======================================================= +/// Every login to hold at once, primary first. `credential_files` wins when set; +/// entries that do not exist are dropped so one stale path cannot shadow a good +/// one. Falls back to `credential_file`, then to Claude Code's own default. +fn resolve_credential_paths(one: Option<&str>, many: Option<&[String]>) -> Vec { + let listed: Vec = many + .unwrap_or_default() + .iter() + .filter(|p| !p.is_empty()) + .map(|p| PathBuf::from(shellexpand::tilde(p).into_owned())) + .filter(|p| { + p.exists() || { + warn!( + "claude credential file {} does not exist; skipping", + p.display() + ); + false + } + }) + .collect(); + if !listed.is_empty() { + let mut deduped: Vec = Vec::with_capacity(listed.len()); + for path in listed { + if !deduped.contains(&path) { + deduped.push(path); + } + } + return deduped; + } + resolve_credential_path(one).into_iter().collect() +} + fn resolve_credential_path(configured: Option<&str>) -> Option { if let Some(path) = configured.filter(|p| !p.is_empty()) { let p = PathBuf::from(shellexpand::tilde(path).into_owned()); @@ -1242,6 +1369,36 @@ fn classify_status(status: reqwest::StatusCode) -> ErrorClass { } } +/// Seconds from a `retry-after` header. The HTTP-date form is not parsed — +/// unreadable reads as "no idea when", which routes to the same place as a long +/// wait: try another login. +fn retry_after_secs(headers: &reqwest::header::HeaderMap) -> Option { + headers + .get(reqwest::header::RETRY_AFTER)? + .to_str() + .ok()? + .trim() + .parse() + .ok() +} + +/// Is this a spent quota window rather than a burst limit? +/// +/// A burst limit comes back in seconds and is cheaper to wait out than to swap +/// for — the swap costs the account's whole cached prefix. Above the ceiling, +/// or with nothing to read, the window is gone and only another login helps. +fn is_exhausted_window(status: u16, retry_after: Option) -> bool { + status == 429 && retry_after.is_none_or(|secs| secs > BURST_RETRY_CEILING_SECS) +} + +fn reset_hint(retry_after: Option) -> String { + match retry_after { + Some(secs) if secs >= 60 => format!(" (resets in ~{}m)", secs / 60), + Some(secs) => format!(" (resets in {secs}s)"), + None => String::new(), + } +} + /// A short, human hint appended to the bail message for status codes whose /// number alone reads as something it isn't — chiefly 529, which looks like /// an OAuth/subscription failure but means Anthropic's capacity, not ours. @@ -1604,6 +1761,53 @@ mod tests { ); } + /// A spent five-hour window is what a second login exists for. A burst + /// limit is not — it clears in seconds, and swapping would abandon a cached + /// prefix worth more than the wait. + #[test] + fn only_a_spent_window_is_worth_another_login() { + assert!(is_exhausted_window(429, None)); + assert!(is_exhausted_window(429, Some(3600))); + assert!(is_exhausted_window(429, Some(61))); + assert!(!is_exhausted_window(429, Some(30))); + assert!(!is_exhausted_window(429, Some(60))); + // Overload and auth failures follow every account; moving is pointless. + assert!(!is_exhausted_window(529, None)); + assert!(!is_exhausted_window(401, None)); + assert!(!is_exhausted_window(500, Some(3600))); + } + + #[test] + fn the_bail_says_when_the_quota_comes_back() { + assert_eq!(reset_hint(Some(7200)), " (resets in ~120m)"); + assert_eq!(reset_hint(Some(45)), " (resets in 45s)"); + assert_eq!(reset_hint(None), ""); + } + + #[test] + fn credential_paths_drop_what_is_missing_and_keep_the_order() { + let dir = tempfile::tempdir().unwrap(); + creds_at(dir.path(), "account-a", now_ms() + 60_000); + let present = dir.path().join(".credentials.json"); + let absent = dir.path().join("no-such-login.json"); + + let listed = vec![ + present.display().to_string(), + absent.display().to_string(), + present.display().to_string(), + ]; + assert_eq!( + resolve_credential_paths(None, Some(&listed)), + vec![present.clone()] + ); + // Nothing listed and nothing on disk falls back to the single path. + let single = absent.display().to_string(); + assert_eq!( + resolve_credential_paths(Some(&single), Some(&[])), + vec![absent] + ); + } + /// The swap that started this: `claude logout` then a login on a second /// account. Nothing expired, nothing was spent, no refresh was attempted — /// and the token we hold belongs to an account the user has left. diff --git a/src/bridge/mod.rs b/src/bridge/mod.rs index 8c4f6e3..c8ce04f 100644 --- a/src/bridge/mod.rs +++ b/src/bridge/mod.rs @@ -50,6 +50,7 @@ pub fn build_provider_from_config( &cfg.primary_model, cfg.timeout_secs, cfg.credential_file.as_deref(), + cfg.credential_files.as_deref(), cfg.cc_version.as_deref(), cfg.account_uuid.as_deref(), cfg.device_id.as_deref(), diff --git a/src/core/config.rs b/src/core/config.rs index 2d47313..1e311e9 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -311,6 +311,12 @@ pub struct ProviderConfig { #[serde(default)] pub credential_file: Option, + /// Additional logins to fall back to when the one in use runs out of quota + /// (for `claude-subscription`). First entry is primary; `credential_file` + /// is used when this is unset. + #[serde(default)] + pub credential_files: Option>, + /// Claude Code version string for wire fingerprinting (for `claude-subscription`). /// Auto-detected from `claude --version` if unset. #[serde(default)] @@ -969,6 +975,7 @@ impl ConsciousnessConfig { primary_model: self.bifrost.primary_model.clone(), timeout_secs: self.bifrost.timeout_secs, credential_file: None, + credential_files: None, cc_version: None, account_uuid: None, device_id: None, @@ -988,6 +995,7 @@ impl ConsciousnessConfig { primary_model: self.zai.primary_model.clone(), timeout_secs: self.zai.timeout_secs, credential_file: None, + credential_files: None, cc_version: None, account_uuid: None, device_id: None, @@ -1008,6 +1016,7 @@ impl ConsciousnessConfig { primary_model: self.bifrost.primary_model.clone(), timeout_secs: self.bifrost.timeout_secs, credential_file: None, + credential_files: None, cc_version: None, account_uuid: None, device_id: None, @@ -1029,6 +1038,7 @@ impl ConsciousnessConfig { primary_model: self.bifrost.primary_model.clone(), timeout_secs: self.bifrost.timeout_secs, credential_file: None, + credential_files: None, cc_version: None, account_uuid: None, device_id: None, diff --git a/src/ui/screens/settings/mod.rs b/src/ui/screens/settings/mod.rs index 73f4fcf..3a95e09 100644 --- a/src/ui/screens/settings/mod.rs +++ b/src/ui/screens/settings/mod.rs @@ -1200,6 +1200,7 @@ mod tests { config.providers.insert( "testbf".into(), crate::core::config::ProviderConfig { + credential_files: None, provider_type: "openai-compatible".into(), base_url: "http://localhost:8080/v1".into(), api_key: String::new(), diff --git a/src/ui/settings/view.rs b/src/ui/settings/view.rs index a8b09c1..eccf994 100644 --- a/src/ui/settings/view.rs +++ b/src/ui/settings/view.rs @@ -853,6 +853,7 @@ impl SettingsView { self.config.providers.insert( name.clone(), crate::core::config::ProviderConfig { + credential_files: None, provider_type: "openai-compatible".to_string(), base_url: String::new(), api_key: String::new(),