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 CC_PLATFORM: &str = "claude_code_cli";
|
||||||
const FALLBACK_CC_VERSION: &str = "2.1.196";
|
const FALLBACK_CC_VERSION: &str = "2.1.196";
|
||||||
const TOKEN_REFRESH_BUFFER: Duration = Duration::from_secs(5 * 60);
|
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;
|
const DEFAULT_MAX_TOKENS: u32 = 8192;
|
||||||
/// Models that think by default cap thinking *plus* response text with
|
/// Models that think by default cap thinking *plus* response text with
|
||||||
/// `max_tokens`, so 8192 truncates mid-answer.
|
/// `max_tokens`, so 8192 truncates mid-answer.
|
||||||
|
|
@ -108,7 +111,19 @@ pub struct ClaudeSubscriptionProvider {
|
||||||
account_uuid: String,
|
account_uuid: String,
|
||||||
extra_metadata: Map<String, Value>,
|
extra_metadata: Map<String, Value>,
|
||||||
default_model: String,
|
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)]
|
#[derive(Clone)]
|
||||||
|
|
@ -120,6 +135,20 @@ struct TokenState {
|
||||||
source_stamp: Option<FileStamp>,
|
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`.
|
/// Identity of the credential file as of the read that produced a `TokenState`.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
struct FileStamp {
|
struct FileStamp {
|
||||||
|
|
@ -138,6 +167,7 @@ impl ClaudeSubscriptionProvider {
|
||||||
primary_model: &str,
|
primary_model: &str,
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
credential_file: Option<&str>,
|
credential_file: Option<&str>,
|
||||||
|
credential_files: Option<&[String]>,
|
||||||
cc_version: Option<&str>,
|
cc_version: Option<&str>,
|
||||||
account_uuid: Option<&str>,
|
account_uuid: Option<&str>,
|
||||||
device_id: Option<&str>,
|
device_id: Option<&str>,
|
||||||
|
|
@ -155,20 +185,34 @@ impl ClaudeSubscriptionProvider {
|
||||||
.build()
|
.build()
|
||||||
.context("failed to build HTTP client")?;
|
.context("failed to build HTTP client")?;
|
||||||
|
|
||||||
let cred_path = resolve_credential_path(credential_file).context(
|
let cred_paths = resolve_credential_paths(credential_file, credential_files);
|
||||||
"no Claude Code credential file found (set credential_file or log in with `claude`)",
|
if cred_paths.is_empty() {
|
||||||
)?;
|
|
||||||
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() {
|
|
||||||
anyhow::bail!(
|
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)) {
|
let device_id = match device_id.filter(|v| is_device_id(v)) {
|
||||||
Some(id) => id.to_ascii_lowercase(),
|
Some(id) => id.to_ascii_lowercase(),
|
||||||
|
|
@ -185,11 +229,8 @@ impl ClaudeSubscriptionProvider {
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"🔐 Claude subscription provider initialized — model: {}, cc_version: {}, creds: {} (cred {})",
|
"🔐 Claude subscription provider initialized — model: {}, cc_version: {}, creds: {} (cred {}, {} spare login(s))",
|
||||||
primary_model,
|
primary_model, cc_version, cred_path, cred_id, spares,
|
||||||
cc_version,
|
|
||||||
cred_path.display(),
|
|
||||||
credential_fingerprint(&token),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
|
@ -202,7 +243,7 @@ impl ClaudeSubscriptionProvider {
|
||||||
account_uuid,
|
account_uuid,
|
||||||
extra_metadata: extra,
|
extra_metadata: extra,
|
||||||
default_model: primary_model.to_string(),
|
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)?)
|
Ok(serde_json::to_vec(&body)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a usable access token, refreshing under the mutex if it is
|
/// Return a usable access token and the account it belongs to, refreshing
|
||||||
/// missing or inside the proactive window.
|
/// under the mutex if it is missing or inside the proactive window.
|
||||||
async fn current_access_token(&self) -> Result<String> {
|
async fn current_access_token(&self) -> Result<(String, usize)> {
|
||||||
let mut token = self.token.lock().await;
|
let mut accounts = self.accounts.lock().await;
|
||||||
adopt_file_if_changed(&mut token);
|
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 access_empty = token.access_token.as_deref().is_none_or(str::is_empty);
|
||||||
let expiring = token
|
let expiring = token
|
||||||
.expires_at_ms
|
.expires_at_ms
|
||||||
.is_none_or(|exp| exp <= now_ms() + TOKEN_REFRESH_BUFFER.as_millis() as u64);
|
.is_none_or(|exp| exp <= now_ms() + TOKEN_REFRESH_BUFFER.as_millis() as u64);
|
||||||
if access_empty || expiring {
|
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
|
// Refresh tokens are one-time-use, and this credential file is
|
||||||
// shared with `claude` itself. Whichever process refreshes
|
// shared with `claude` itself. Whichever process refreshes
|
||||||
// first rotates the token out from under the other, so a
|
// 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
|
// revoked. Re-read the file before giving up: if it now holds a
|
||||||
// newer, still-valid token, adopt it. Without this the provider
|
// newer, still-valid token, adopt it. Without this the provider
|
||||||
// 400s on every turn until the service is restarted by hand.
|
// 400s on every turn until the service is restarted by hand.
|
||||||
match reread_credentials(&token) {
|
match reread_credentials(token) {
|
||||||
Some(rotated) => {
|
Some(rotated) => {
|
||||||
info!("claude token refresh failed ({e}); adopted rotated credentials");
|
info!("claude token refresh failed ({e}); adopted rotated credentials");
|
||||||
*token = rotated;
|
*token = rotated;
|
||||||
|
|
@ -282,13 +325,48 @@ impl ClaudeSubscriptionProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
token
|
let access = token
|
||||||
.access_token
|
.access_token
|
||||||
.clone()
|
.clone()
|
||||||
.filter(|t| !t.is_empty())
|
.filter(|t| !t.is_empty())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
anyhow::anyhow!("no claude access_token available (refresh failed and none cached)")
|
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>> {
|
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
|
let resp = self
|
||||||
.http
|
.http
|
||||||
.get(format!("{}/v1/models", self.upstream_base))
|
.get(format!("{}/v1/models", self.upstream_base))
|
||||||
|
|
@ -345,7 +423,7 @@ impl LlmProvider for ClaudeSubscriptionProvider {
|
||||||
let body = self.shape_body(&model, &request)?;
|
let body = self.shape_body(&model, &request)?;
|
||||||
|
|
||||||
for attempt in 0..max_attempts {
|
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
|
let resp = self
|
||||||
.http
|
.http
|
||||||
.post(format!("{}/v1/messages", self.upstream_base))
|
.post(format!("{}/v1/messages", self.upstream_base))
|
||||||
|
|
@ -429,7 +507,24 @@ impl LlmProvider for ClaudeSubscriptionProvider {
|
||||||
return Ok((result, strain));
|
return Ok((result, strain));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let retry_after = retry_after_secs(resp.headers());
|
||||||
let body_text = resp.text().await.unwrap_or_default();
|
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) {
|
match classify_status(status) {
|
||||||
ErrorClass::Transient if attempt + 1 < max_attempts => {
|
ErrorClass::Transient if attempt + 1 < max_attempts => {
|
||||||
let delay = backoff(attempt);
|
let delay = backoff(attempt);
|
||||||
|
|
@ -455,9 +550,10 @@ impl LlmProvider for ClaudeSubscriptionProvider {
|
||||||
body: body_text.chars().take(300).collect(),
|
body: body_text.chars().take(300).collect(),
|
||||||
});
|
});
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"claude subscription returned {status} after {} attempt(s) on {model}{}: {}",
|
"claude subscription returned {status} after {} attempt(s) on {model}{}{}: {}",
|
||||||
attempt + 1,
|
attempt + 1,
|
||||||
status_hint(status.as_u16()),
|
status_hint(status.as_u16()),
|
||||||
|
reset_hint(retry_after),
|
||||||
&body_text[..body_text.len().min(500)]
|
&body_text[..body_text.len().min(500)]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -647,6 +743,37 @@ async fn fetch_account_uuid(
|
||||||
|
|
||||||
// ── credentials file =======================================================
|
// ── 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> {
|
fn resolve_credential_path(configured: Option<&str>) -> Option<PathBuf> {
|
||||||
if let Some(path) = configured.filter(|p| !p.is_empty()) {
|
if let Some(path) = configured.filter(|p| !p.is_empty()) {
|
||||||
let p = PathBuf::from(shellexpand::tilde(path).into_owned());
|
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
|
/// 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
|
/// number alone reads as something it isn't — chiefly 529, which looks like
|
||||||
/// an OAuth/subscription failure but means Anthropic's capacity, not ours.
|
/// 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
|
/// The swap that started this: `claude logout` then a login on a second
|
||||||
/// account. Nothing expired, nothing was spent, no refresh was attempted —
|
/// account. Nothing expired, nothing was spent, no refresh was attempted —
|
||||||
/// and the token we hold belongs to an account the user has left.
|
/// 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.primary_model,
|
||||||
cfg.timeout_secs,
|
cfg.timeout_secs,
|
||||||
cfg.credential_file.as_deref(),
|
cfg.credential_file.as_deref(),
|
||||||
|
cfg.credential_files.as_deref(),
|
||||||
cfg.cc_version.as_deref(),
|
cfg.cc_version.as_deref(),
|
||||||
cfg.account_uuid.as_deref(),
|
cfg.account_uuid.as_deref(),
|
||||||
cfg.device_id.as_deref(),
|
cfg.device_id.as_deref(),
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,12 @@ pub struct ProviderConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub credential_file: Option<String>,
|
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`).
|
/// Claude Code version string for wire fingerprinting (for `claude-subscription`).
|
||||||
/// Auto-detected from `claude --version` if unset.
|
/// Auto-detected from `claude --version` if unset.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -969,6 +975,7 @@ impl ConsciousnessConfig {
|
||||||
primary_model: self.bifrost.primary_model.clone(),
|
primary_model: self.bifrost.primary_model.clone(),
|
||||||
timeout_secs: self.bifrost.timeout_secs,
|
timeout_secs: self.bifrost.timeout_secs,
|
||||||
credential_file: None,
|
credential_file: None,
|
||||||
|
credential_files: None,
|
||||||
cc_version: None,
|
cc_version: None,
|
||||||
account_uuid: None,
|
account_uuid: None,
|
||||||
device_id: None,
|
device_id: None,
|
||||||
|
|
@ -988,6 +995,7 @@ impl ConsciousnessConfig {
|
||||||
primary_model: self.zai.primary_model.clone(),
|
primary_model: self.zai.primary_model.clone(),
|
||||||
timeout_secs: self.zai.timeout_secs,
|
timeout_secs: self.zai.timeout_secs,
|
||||||
credential_file: None,
|
credential_file: None,
|
||||||
|
credential_files: None,
|
||||||
cc_version: None,
|
cc_version: None,
|
||||||
account_uuid: None,
|
account_uuid: None,
|
||||||
device_id: None,
|
device_id: None,
|
||||||
|
|
@ -1008,6 +1016,7 @@ impl ConsciousnessConfig {
|
||||||
primary_model: self.bifrost.primary_model.clone(),
|
primary_model: self.bifrost.primary_model.clone(),
|
||||||
timeout_secs: self.bifrost.timeout_secs,
|
timeout_secs: self.bifrost.timeout_secs,
|
||||||
credential_file: None,
|
credential_file: None,
|
||||||
|
credential_files: None,
|
||||||
cc_version: None,
|
cc_version: None,
|
||||||
account_uuid: None,
|
account_uuid: None,
|
||||||
device_id: None,
|
device_id: None,
|
||||||
|
|
@ -1029,6 +1038,7 @@ impl ConsciousnessConfig {
|
||||||
primary_model: self.bifrost.primary_model.clone(),
|
primary_model: self.bifrost.primary_model.clone(),
|
||||||
timeout_secs: self.bifrost.timeout_secs,
|
timeout_secs: self.bifrost.timeout_secs,
|
||||||
credential_file: None,
|
credential_file: None,
|
||||||
|
credential_files: None,
|
||||||
cc_version: None,
|
cc_version: None,
|
||||||
account_uuid: None,
|
account_uuid: None,
|
||||||
device_id: None,
|
device_id: None,
|
||||||
|
|
|
||||||
|
|
@ -1200,6 +1200,7 @@ mod tests {
|
||||||
config.providers.insert(
|
config.providers.insert(
|
||||||
"testbf".into(),
|
"testbf".into(),
|
||||||
crate::core::config::ProviderConfig {
|
crate::core::config::ProviderConfig {
|
||||||
|
credential_files: None,
|
||||||
provider_type: "openai-compatible".into(),
|
provider_type: "openai-compatible".into(),
|
||||||
base_url: "http://localhost:8080/v1".into(),
|
base_url: "http://localhost:8080/v1".into(),
|
||||||
api_key: String::new(),
|
api_key: String::new(),
|
||||||
|
|
|
||||||
|
|
@ -853,6 +853,7 @@ impl SettingsView {
|
||||||
self.config.providers.insert(
|
self.config.providers.insert(
|
||||||
name.clone(),
|
name.clone(),
|
||||||
crate::core::config::ProviderConfig {
|
crate::core::config::ProviderConfig {
|
||||||
|
credential_files: None,
|
||||||
provider_type: "openai-compatible".to_string(),
|
provider_type: "openai-compatible".to_string(),
|
||||||
base_url: String::new(),
|
base_url: String::new(),
|
||||||
api_key: String::new(),
|
api_key: String::new(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue