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:
parent
f4837c52a8
commit
16587b74a3
5 changed files with 247 additions and 30 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue