Watch
1
0
Fork
You've already forked souveraine
0

claude: hold every login at once, move when a window is spent

`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.
This commit is contained in:
Fimeg 2026-08-12 16:45:52 -04:00
commit 16587b74a3
5 changed files with 247 additions and 30 deletions

View file

@ -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<String, Value>,
default_model: String,
token: Arc<Mutex<TokenState>>,
accounts: Arc<Mutex<Accounts>>,
}
/// 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<TokenState>,
active: usize,
}
#[derive(Clone)]
@ -120,6 +135,20 @@ struct TokenState {
source_stamp: Option<FileStamp>,
}
impl TokenState {
fn has_credential(&self) -> bool {
let live = |t: &Option<String>| 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 => "<unnamed>".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::<Vec<_>>()
.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<String> {
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<usize> {
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<Vec<String>> {
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<PathBuf> {
let listed: Vec<PathBuf> = 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<PathBuf> = 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<PathBuf> {
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<u64> {
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<u64>) -> bool {
status == 429 && retry_after.is_none_or(|secs| secs > BURST_RETRY_CEILING_SECS)
}
fn reset_hint(retry_after: Option<u64>) -> 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.

View file

@ -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(),

View file

@ -311,6 +311,12 @@ pub struct ProviderConfig {
#[serde(default)]
pub credential_file: Option<String>,
/// 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<Vec<String>>,
/// 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,

View file

@ -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(),

View file

@ -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(),