pub mod codex;
pub mod creds;
pub mod identity;
pub mod pick;
pub mod ratelimit;
pub mod upstream;
use crate::paths::Paths;
use anyhow::{anyhow, Result};
use std::collections::HashMap;
use std::io::Write;
use std::sync::{Arc, Mutex};
#[derive(Clone, Copy, Debug, Default)]
struct Measurement {
five_h: Option<f64>,
seven_d: Option<f64>,
credits: bool,
five_h_reset: Option<i64>,
seven_d_reset: Option<i64>,
taken: Option<std::time::Instant>,
}
impl Measurement {
fn resets_in(&self, now: i64) -> Option<i64> {
[self.five_h_reset, self.seven_d_reset]
.into_iter()
.flatten()
.min()
.map(|r| r - now)
}
}
type Utilization = Measurement;
type Measured = (Option<std::time::Instant>, HashMap<String, Utilization>);
struct Shared {
agent: ureq::Agent,
base: String,
quota: Mutex<HashMap<String, (ratelimit::Quota, i64)>>,
chooser: Mutex<pick::Chooser>,
rotated: Mutex<Option<String>>,
unusable: Mutex<pick::Sidelined>,
measured: Mutex<Measured>,
cornered: Mutex<bool>,
last_preempt: Mutex<Option<std::time::Instant>>,
}
pub struct Opts {
pub port: u16,
pub account: Option<String>,
pub tool: String,
pub auto: Option<bool>,
pub threshold: Option<f64>,
pub threshold_pinned: bool,
}
fn auto_now(flag: Option<bool>, setting: bool) -> bool {
flag.unwrap_or(setting)
}
fn live(paths: &Paths, opts: &Opts) -> (bool, Option<f64>) {
let cfg = crate::settings::load(paths);
let auto = auto_now(opts.auto, cfg.auto());
let threshold = if opts.threshold_pinned {
opts.threshold
} else {
cfg.threshold()
};
(auto, threshold.filter(|_| auto))
}
fn is_auth_exchange(path: &str) -> bool {
let p = path.split('?').next().unwrap_or(path).to_ascii_lowercase();
p.split('/')
.any(|seg| matches!(seg, "oauth" | "login" | "logout" | "authorize" | "auth"))
}
fn skip_header(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"host" | "connection" | "content-length" | "transfer-encoding" | "accept-encoding"
)
}
fn pick_slot(paths: &Paths, opts: &Opts, sh: &Arc<Shared>) -> Result<crate::slots::SlotRecord> {
let slots = crate::slots::Slots::open_for(paths, &opts.tool)?;
if let Some(name) = &opts.account {
return slots
.get(name)
.ok_or_else(|| anyhow!("no account slot named '{name}' - `swapdex slots` lists them"));
}
let list = slots.list();
let pointer = slots.serving_dir().or_else(|| slots.default_dir());
let rotated = sh.rotated.lock().unwrap().clone();
let chosen = sh
.chooser
.lock()
.unwrap()
.choose(pointer.as_deref(), rotated.as_deref(), &list)
.ok_or_else(|| anyhow!("no account slots yet - `swapdex run <name>` creates one"))?;
let (auto, live_threshold) = live(paths, opts);
if auto {
if let Some(t) = live_threshold.filter(|_| opts.tool != "codex") {
refresh_measured(paths, &list, sh);
let full = sh
.measured
.lock()
.unwrap()
.1
.get(&chosen.name)
.is_some_and(|m| pick::over_threshold_with(m.five_h, m.seven_d, t, m.credits));
let cooling = sh
.last_preempt
.lock()
.unwrap()
.is_some_and(|t| t.elapsed() < PREEMPT_COOLDOWN);
if full && !cooling {
match usable_under_threshold(paths, sh, &chosen.name, t) {
Some(better) => {
println!(
"{} is near its limit - starting this turn on {}",
chosen.name, better.name
);
std::io::stdout().flush().ok();
*sh.cornered.lock().unwrap() = false;
*sh.rotated.lock().unwrap() = Some(better.name.clone());
*sh.last_preempt.lock().unwrap() = Some(std::time::Instant::now());
return Ok(better);
}
None => {
println!(
"{} is past the threshold, and no other account is below it - \
staying here",
chosen.name
);
std::io::stdout().flush().ok();
*sh.cornered.lock().unwrap() = true;
}
}
}
}
let known_spent = sh
.quota
.lock()
.unwrap()
.get(&chosen.name)
.is_some_and(|(q, at)| q.still_spent_since(*at, now_secs()))
|| sh
.unusable
.lock()
.unwrap()
.contains(&chosen.name, std::time::Instant::now())
|| creds::slot_token_expired(&chosen.config_dir, now_ms());
if known_spent {
if let Some(better) = next_account(paths, sh, std::slice::from_ref(&chosen.name)) {
println!(
"{} is benched - this turn goes to {}",
chosen.name, better.name
);
std::io::stdout().flush().ok();
return Ok(better);
}
}
}
Ok(chosen)
}
const MEASURE_EVERY: std::time::Duration = std::time::Duration::from_secs(60);
const KEEP_ALIVE_EVERY: std::time::Duration = std::time::Duration::from_secs(30 * 60);
fn spawn_keep_alive(paths: &Paths, tool: &str) {
if tool == "codex" {
return;
}
let paths = paths.clone();
std::thread::spawn(move || loop {
std::thread::sleep(KEEP_ALIVE_EVERY);
let slots: Vec<(String, std::path::PathBuf)> =
match crate::slots::Slots::open_for(&paths, "claude-code") {
Ok(s) => s
.list()
.into_iter()
.map(|r| (r.name, r.config_dir))
.collect(),
Err(_) => continue,
};
let (renewed, failed) = crate::refresh::keep_alive_sweep(&slots, now_ms());
for name in &renewed {
println!("keep-alive: renewed {name}");
}
for (name, why) in &failed {
println!("keep-alive: {}", why.remedy(name));
}
if !renewed.is_empty() || !failed.is_empty() {
std::io::stdout().flush().ok();
}
});
}
const PREEMPT_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(300);
fn refresh_measured(paths: &Paths, slots: &[crate::slots::SlotRecord], sh: &Arc<Shared>) {
let first = {
let mut m = sh.measured.lock().unwrap();
match m.0 {
Some(t) if t.elapsed() < MEASURE_EVERY => return,
Some(_) => {
m.0 = Some(std::time::Instant::now());
false
}
None => true,
}
};
if first {
measure_now(paths, slots, sh);
return;
}
let slots: Vec<crate::slots::SlotRecord> = slots.to_vec();
let sh = Arc::clone(sh);
let paths = paths.clone();
std::thread::spawn(move || measure_now(&paths, &slots, &sh));
}
fn seed_from_cache(cache: &crate::quota_cache::Cache, now: i64) -> HashMap<String, Measurement> {
cache
.iter()
.map(|(name, e)| {
let age = (now - e.at).max(0) as u64;
let taken = std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(age))
.unwrap_or_else(std::time::Instant::now);
(
name.clone(),
Measurement {
five_h: e.five_h,
seven_d: e.seven_d,
credits: e.on_credits,
five_h_reset: e.five_h_reset,
seven_d_reset: e.seven_d_reset,
taken: Some(taken),
},
)
})
.collect()
}
fn measure_now(paths: &Paths, slots: &[crate::slots::SlotRecord], sh: &Shared) {
let mut out: HashMap<String, Measurement> = {
let m = sh.measured.lock().unwrap();
m.1.clone()
};
let now = std::time::Instant::now();
let mut unread: Vec<(String, String)> = Vec::new();
let mut just_read: std::collections::HashSet<String> = std::collections::HashSet::new();
let refused: Vec<String> = {
let q = sh.quota.lock().unwrap();
let now_s = now_secs();
slots
.iter()
.filter(|r| {
q.get(&r.name)
.is_some_and(|(quota, at)| quota.still_spent_since(*at, now_s))
})
.map(|r| r.name.clone())
.collect()
};
for r in slots {
if let Some(prev) = out.get(&r.name) {
let due = prev.taken.is_none_or(|t| {
now.duration_since(t)
>= pick::measure_after(pick::headroom(prev.five_h, prev.seven_d))
});
if !due {
continue;
}
}
let tok = match creds::slot_token_detail(&r.config_dir) {
Ok(t) => t,
Err(why) => {
unread.push((r.name.clone(), why.short().to_string()));
continue;
}
};
let token = String::from_utf8_lossy(tok.expose()).to_string();
if !crate::quota::token_usable(&token) {
unread.push((
r.name.clone(),
"token lapsed - serving renews it, measuring does not".to_string(),
));
continue;
}
crate::quota::pace_between_accounts();
just_read.insert(r.name.clone());
let fetched = crate::quota::fetch_with_retry(&token);
if let Some(why) = fetched.why_no_number().map(str::to_string) {
unread.push((r.name.clone(), why));
}
if let crate::quota::Fetch::Ok(q) = fetched {
out.insert(
r.name.clone(),
Measurement {
five_h: q.five_hour.map(|w| w.used_pct),
seven_d: q.seven_day.map(|w| w.used_pct),
credits: q.can_serve_past_windows(),
five_h_reset: q.five_hour.and_then(|w| w.resets_at),
seven_d_reset: q.seven_day.and_then(|w| w.resets_at),
taken: Some(std::time::Instant::now()),
},
);
}
}
if out.is_empty() {
let why = if unread.is_empty() {
String::new()
} else {
format!(": {}", pick::usage_line(&[], &unread, &refused))
};
println!(" (no account's usage could be read{why} - the threshold cannot apply)");
} else {
let now_s = now_secs();
let tz = tz_offset();
let win = |pct: Option<f64>, at: Option<i64>, label: &str| -> Option<String> {
let used = pct?;
let when = at.map(|t| pick::reset_clock(t, now_s, tz));
Some(pick::window_left(label, used, when))
};
let measured: Vec<(String, String)> = out
.iter()
.map(|(n, m)| {
let via = if m.credits { " (on credits)" } else { "" };
let parts: Vec<String> = [
win(m.five_h, m.five_h_reset, "5h"),
win(m.seven_d, m.seven_d_reset, "7d"),
]
.into_iter()
.flatten()
.collect();
let value = if parts.is_empty() {
"?".to_string()
} else {
format!("{}{via}", parts.join(" · "))
};
(n.clone(), value)
})
.collect();
println!(" usage:");
for line in pick::usage_block(&measured, &unread, &refused) {
println!(" {line}");
}
}
std::io::stdout().flush().ok();
let fresh: Vec<(String, crate::quota_cache::Entry)> = out
.iter()
.filter(|(n, _)| just_read.contains(*n))
.map(|(n, m)| {
(
n.clone(),
crate::quota_cache::Entry {
five_h: m.five_h,
five_h_reset: m.five_h_reset,
seven_d: m.seven_d,
seven_d_reset: m.seven_d_reset,
at: now_secs(),
on_credits: m.credits,
},
)
})
.collect();
if !fresh.is_empty() {
crate::quota_cache::update(paths, &fresh);
}
let mut m = sh.measured.lock().unwrap();
*m = (Some(std::time::Instant::now()), out);
}
fn usable_under_threshold(
paths: &Paths,
sh: &Shared,
current: &str,
threshold: f64,
) -> Option<crate::slots::SlotRecord> {
let mut slots = crate::slots::Slots::open(paths).map(|s| s.list()).ok()?;
let cfg = crate::settings::load(paths);
let measured = sh.measured.lock().unwrap();
let spent = sh.quota.lock().unwrap();
let unusable = sh.unusable.lock().unwrap();
let now = std::time::Instant::now();
let now_s = now_secs();
let room = |r: &crate::slots::SlotRecord| {
measured
.1
.get(&r.name)
.and_then(|m| pick::headroom(m.five_h, m.seven_d))
};
pick::order_by(
&mut slots,
cfg.strategy(),
|r| cfg.rank(&r.name),
room,
|r| measured.1.get(&r.name).and_then(|m| m.resets_in(now_s)),
);
let here = measured
.1
.get(current)
.and_then(|m| pick::headroom(m.five_h, m.seven_d));
slots.into_iter().find(|r| {
r.name != current
&& !cfg.is_disabled(&r.name)
&& !unusable.contains(&r.name, now)
&& !spent
.get(&r.name)
.is_some_and(|(q, at)| q.still_spent_since(*at, now_s))
&& !measured
.1
.get(&r.name)
.is_some_and(|m| {
pick::over_threshold_with(m.five_h, m.seven_d, threshold, m.credits)
})
&& creds::slot_token(&r.config_dir).is_some()
&& (cfg.strategy() == pick::Strategy::ConsumeFirst
|| pick::worth_moving_to(here, room(r), pick::HYSTERESIS_MARGIN))
})
}
fn ctrl_c_cleanup<F: Fn() + Send + Sync + 'static>(f: F) -> Result<()> {
use std::sync::OnceLock;
static HOOK: OnceLock<Box<dyn Fn() + Send + Sync>> = OnceLock::new();
let boxed: Box<dyn Fn() + Send + Sync> = Box::new(f);
if HOOK.set(boxed).is_err() {
return Ok(());
}
extern "C" fn on_signal(sig: libc::c_int) {
if let Some(f) = HOOK.get() {
f();
}
unsafe {
libc::signal(sig, libc::SIG_DFL);
libc::raise(sig);
}
}
let handler = on_signal as extern "C" fn(libc::c_int) as *const () as libc::sighandler_t;
unsafe {
libc::signal(libc::SIGINT, handler);
libc::signal(libc::SIGTERM, handler);
}
Ok(())
}
pub fn serve(paths: &Paths, opts: &Opts) -> Result<()> {
crate::atomic::ensure_not_root()?;
if opts.tool != "codex" {
let reads: Vec<_> = crate::slots::Slots::open_for(paths, &opts.tool)
.map(|s| {
s.list()
.into_iter()
.map(|r| creds::slot_token_detail(&r.config_dir).map(|_| ()))
.collect()
})
.unwrap_or_default();
if let Some(why) = creds::startup_refusal(&reads) {
return Err(anyhow!("{why}"));
}
}
let server = match tiny_http::Server::http(("127.0.0.1", opts.port)) {
Ok(s) => s,
Err(e) => take_the_port(paths, &opts.tool, opts.port)
.ok_or_else(|| anyhow!("cannot bind 127.0.0.1:{}: {e}", opts.port))?,
};
let port = server
.server_addr()
.to_ip()
.ok_or_else(|| anyhow!("proxy did not get a TCP port"))?
.port();
let marker = crate::shim::proxy_marker_for(paths, &opts.tool);
let _ = std::fs::create_dir_all(paths.store_dir());
let announced = std::fs::write(
&marker,
format!("{} {port} {}\n", std::process::id(), build_id()),
)
.is_ok();
if announced {
let m = marker.clone();
let serving = serving_file_for(paths, &opts.tool);
let _ = ctrl_c_cleanup(move || {
let _ = std::fs::remove_file(&m);
let _ = std::fs::remove_file(&serving);
});
}
let is_codex = opts.tool == "codex";
let bin = if is_codex { "codex" } else { "claude" };
println!("swapdex {bin} proxy listening on http://127.0.0.1:{port}");
if announced && crate::shim::shim_path_for(paths, &opts.tool).exists() {
println!(" a plain `{bin}` now goes through it (the shim picks it up)");
} else if is_codex {
println!(" point Codex at it: codex -c model_provider=swapdex \\");
println!(" -c model_providers.swapdex.name=swapdex \\");
println!(" -c model_providers.swapdex.base_url=http://127.0.0.1:{port}/v1 \\");
println!(" -c model_providers.swapdex.wire_api=responses");
} else {
println!(" point Claude at it: export ANTHROPIC_BASE_URL=http://127.0.0.1:{port}");
}
let (auto_now_, thr_now) = live(paths, opts);
match (auto_now_, thr_now.filter(|_| !is_codex)) {
(true, Some(t)) => println!(
" auto: hands the session on at {:.0}% used, or when an account refuses",
(t * 100.0).round()
),
(true, None) => println!(" auto: hands the session on when an account refuses"),
(false, _) => println!(" auto is off - `swapdex auto on` lets it move by itself"),
}
std::io::stdout().flush().ok();
spawn_keep_alive(paths, &opts.tool);
let server = Arc::new(server);
let sh = Arc::new(Shared {
agent: upstream::agent(),
base: if opts.tool == "codex" {
codex::base_url()
} else {
upstream::base_url()
},
quota: Mutex::new(HashMap::new()),
chooser: Mutex::new(pick::Chooser::default()),
rotated: Mutex::new(None),
unusable: Mutex::new(pick::Sidelined::default()),
cornered: Mutex::new(false),
measured: Mutex::new((
None,
seed_from_cache(&crate::quota_cache::load(paths), now_secs()),
)),
last_preempt: Mutex::new(None),
});
loop {
let rq = match server.recv() {
Ok(r) => r,
Err(_) => continue,
};
let paths = paths.clone();
let sh = sh.clone();
let opts = Opts {
port,
account: opts.account.clone(),
tool: opts.tool.clone(),
auto: opts.auto,
threshold: opts.threshold,
threshold_pinned: opts.threshold_pinned,
};
std::thread::spawn(move || {
if let Err(e) = handle(rq, &paths, &opts, &sh) {
eprintln!("swapdex proxy: {e:#}");
}
});
}
}
fn next_account_for(
paths: &Paths,
opts: &Opts,
sh: &Shared,
tried: &[String],
) -> Option<crate::slots::SlotRecord> {
next_account_in(paths, &opts.tool, sh, tried)
}
fn next_account(paths: &Paths, sh: &Shared, tried: &[String]) -> Option<crate::slots::SlotRecord> {
next_account_in(paths, "claude-code", sh, tried)
}
fn next_account_in(
paths: &Paths,
tool: &str,
sh: &Shared,
tried: &[String],
) -> Option<crate::slots::SlotRecord> {
let mut slots = crate::slots::Slots::open_for(paths, tool)
.map(|s| s.list())
.ok()?;
let cfg = crate::settings::load(paths);
{
let measured = sh.measured.lock().unwrap();
pick::by_headroom(
&mut slots,
|r| cfg.rank(&r.name),
|r| {
measured
.1
.get(&r.name)
.and_then(|m| pick::headroom(m.five_h, m.seven_d))
},
);
}
let spent = sh.quota.lock().unwrap();
let unusable = sh.unusable.lock().unwrap();
let now = std::time::Instant::now();
let now_s = now_secs();
let candidates: Vec<pick::Candidate> = slots
.iter()
.map(|r| pick::Candidate {
name: r.name.clone(),
uuid: creds::slot_account_uuid(&r.config_dir),
ruled_out: tried.contains(&r.name)
|| unusable.contains(&r.name, now)
|| spent
.get(&r.name)
.is_some_and(|(q, at)| q.still_spent_since(*at, now_s))
|| cfg.is_disabled(&r.name),
usable: has_usable_login(tool, &r.config_dir),
})
.collect();
let chosen = pick::next_usable(&candidates)?.name.clone();
slots.into_iter().find(|r| r.name == chosen)
}
pub fn has_login(tool: &str, dir: &std::path::Path) -> bool {
match tool {
"codex" => codex::slot_auth(dir).is_some(),
_ => login_present(creds::slot_token_detail(dir)),
}
}
pub fn login_present(read: Result<crate::secret::Secret, creds::TokenUnavailable>) -> bool {
!matches!(read, Err(creds::TokenUnavailable::NoLogin))
}
fn has_usable_login(tool: &str, dir: &std::path::Path) -> bool {
if tool != "codex" && creds::slot_token_expired(dir, now_ms()) {
let _ = crate::refresh::refresh_slot(dir, now_ms());
}
match tool {
"codex" => codex::slot_auth(dir).is_some(),
_ => creds::slot_token(dir).is_some() && !creds::slot_token_expired(dir, now_ms()),
}
}
fn now_secs() -> i64 {
now_ms() / 1000
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn handle(mut rq: tiny_http::Request, paths: &Paths, opts: &Opts, sh: &Arc<Shared>) -> Result<()> {
let up = match forward_turn(&mut rq, paths, opts, sh) {
Ok(up) => up,
Err(e) => {
let msg = format!("{e:#}");
let body = serde_json::json!({
"type": "error",
"error": { "type": "swapdex_proxy_error", "message": msg.clone() }
})
.to_string();
let resp = tiny_http::Response::from_string(body)
.with_status_code(tiny_http::StatusCode(502))
.with_header(
tiny_http::Header::from_bytes(&b"content-type"[..], &b"application/json"[..])
.expect("static header"),
);
let _ = rq.respond(resp);
return Err(anyhow!(msg));
}
};
let out_headers: Vec<tiny_http::Header> = up
.headers
.iter()
.filter(|(n, _)| !skip_header(n))
.filter_map(|(n, v)| tiny_http::Header::from_bytes(n.as_bytes(), v.as_bytes()).ok())
.collect();
let resp = tiny_http::Response::new(
tiny_http::StatusCode(up.status),
out_headers,
up.reader,
None,
None,
);
rq.respond(resp)?;
Ok(())
}
fn forward_turn(
rq: &mut tiny_http::Request,
paths: &Paths,
opts: &Opts,
sh: &Arc<Shared>,
) -> Result<upstream::Upstream> {
let client_auth = rq
.headers()
.iter()
.find(|h| h.field.equiv("authorization"))
.map(|h| h.value.as_str().to_string());
let client_headers: Vec<(String, String)> = rq
.headers()
.iter()
.filter(|h| !skip_header(h.field.as_str().as_str()))
.filter(|h| !h.field.equiv("authorization"))
.map(|h| {
(
h.field.as_str().as_str().to_string(),
h.value.as_str().to_string(),
)
})
.collect();
let is_codex = opts.tool == "codex";
let url = if is_codex {
codex::upstream_url(&sh.base, rq.url())
} else {
format!("{}{}", sh.base, rq.url())
};
let method = rq.method().as_str().to_string();
let path = rq.url().to_string();
let mut client_body = Vec::new();
rq.as_reader().read_to_end(&mut client_body)?;
let known_uuids: Vec<String> = crate::slots::Slots::open(paths)
.map(|s| {
s.list()
.iter()
.filter_map(|r| creds::slot_account_uuid(&r.config_dir))
.collect()
})
.unwrap_or_default();
if is_auth_exchange(&path) {
println!(" {method} {path} -> signing in, passed through untouched");
std::io::stdout().flush().ok();
let mut headers = client_headers.clone();
if let Some(auth) = client_auth.clone() {
headers.push(("authorization".into(), auth));
}
return upstream::forward(&sh.agent, &method, &url, &headers, &client_body);
}
let mut slot = pick_slot(paths, opts, sh)?;
let mut tried: Vec<String> = Vec::new();
let mut attempt = 0u32;
let mut refused_by: Option<String> = None;
let up = loop {
if creds::slot_token_expired(&slot.config_dir, now_ms()) {
match crate::refresh::refresh_slot(&slot.config_dir, now_ms()) {
Ok(()) => println!(" {}: renewed its login", slot.name),
Err(why) => println!(" {}", why.remedy(&slot.name)),
}
std::io::stdout().flush().ok();
}
if creds::slot_token_expired(&slot.config_dir, now_ms()) {
println!(
"{}: its login has expired - passing your own login through \
(`swapdex run {}` once refreshes it)",
slot.name, slot.name
);
std::io::stdout().flush().ok();
let mut headers = client_headers.clone();
if let Some(auth) = client_auth.clone() {
headers.push(("authorization".into(), auth));
}
return upstream::forward(&sh.agent, &method, &url, &headers, &client_body);
}
if is_codex {
let Some(auth) = codex::slot_auth(&slot.config_dir) else {
println!(
"account '{}' has no usable Codex login - passing your own through \
(`swapdex run {} --tool codex` once signs it in)",
slot.name, slot.name
);
std::io::stdout().flush().ok();
note_client_serving(paths, &opts.tool);
let mut headers = client_headers.clone();
if let Some(a) = client_auth.clone() {
headers.push(("authorization".into(), a));
}
return upstream::forward(&sh.agent, &method, &url, &headers, &client_body);
};
let mut headers = client_headers.clone();
codex::apply_auth(&mut headers, &auth);
note_serving_for(paths, &opts.tool, &slot.name);
let up = upstream::forward(&sh.agent, &method, &url, &headers, &client_body)?;
println!(" {} {} -> {} [{}]", method, path, up.status, slot.name);
std::io::stdout().flush().ok();
if up.status == 429 {
if let ratelimit::Throttle::RetryAfter(wait) =
ratelimit::classify_429(&up.headers, attempt)
{
attempt += 1;
println!(" {} throttled - retrying in {:?}", slot.name, wait);
std::io::stdout().flush().ok();
std::thread::sleep(wait);
continue;
}
}
if ratelimit::account_cannot_serve(up.status)
&& live(paths, opts).0
&& opts.account.is_none()
{
tried.push(slot.name.clone());
if up.status == 401 || up.status == 403 {
sh.unusable
.lock()
.unwrap()
.mark(&slot.name, std::time::Instant::now());
} else if ratelimit::proven_spent(&up.headers, attempt) {
let mut spent = sh.quota.lock().unwrap();
let e = spent.entry(slot.name.clone()).or_default();
e.0.rejected = true;
e.1 = now_secs();
}
if let Some(next) = next_account_for(paths, opts, sh, &tried) {
println!(" {} is out - continuing on {}", slot.name, next.name);
std::io::stdout().flush().ok();
*sh.rotated.lock().unwrap() = Some(next.name.clone());
note_serving_for(paths, &opts.tool, &next.name);
slot = next;
continue;
}
}
break up;
}
let token = match creds::slot_token_detail(&slot.config_dir) {
Ok(t) => t,
Err(why) => {
println!(
"{} - passing your own login through",
why.remedy(&slot.name)
);
std::io::stdout().flush().ok();
note_client_serving(paths, &opts.tool);
let mut headers = client_headers.clone();
if let Some(auth) = client_auth.clone() {
headers.push(("authorization".into(), auth));
}
return upstream::forward(&sh.agent, &method, &url, &headers, &client_body);
}
};
let mut headers = client_headers.clone();
headers.push((
"authorization".into(),
format!("Bearer {}", String::from_utf8_lossy(token.expose())),
));
let mut body = client_body.clone();
if *sh.cornered.lock().unwrap() {
if let Some(m) = crate::settings::load(paths).fallback_model.as_deref() {
if let Some(swapped) = identity::swap_model(&body, m) {
println!(" every account is past the threshold - asking for {m} instead");
std::io::stdout().flush().ok();
body = swapped;
}
}
}
if let Some(serving) = creds::slot_account_uuid(&slot.config_dir) {
if let Some(aligned) = identity::align_account(&body, &known_uuids, &serving) {
body = aligned;
}
}
note_serving_for(paths, &opts.tool, &slot.name);
let up = loop {
let up = upstream::forward(&sh.agent, &method, &url, &headers, &body)?;
if up.status != 429 {
break up;
}
match ratelimit::classify_429(&up.headers, attempt) {
ratelimit::Throttle::RetryAfter(wait) => {
println!(
"{} {path} -> 429 throttled, retrying in {}s",
slot.name,
wait.as_secs()
);
std::io::stdout().flush().ok();
drop(up); attempt += 1;
std::thread::sleep(wait);
}
ratelimit::Throttle::Exhausted => break up,
}
};
let quota = ratelimit::from_headers(&up.headers);
match "a {
Some(q) if q.rejected => println!(
"{} {path} -> {} ({} spent)",
slot.name,
up.status,
q.rejected_windows().join(", ")
),
_ => println!("{} {path} -> {}", slot.name, up.status),
}
std::io::stdout().flush().ok();
if let Some(q) = quota {
sh.quota
.lock()
.unwrap()
.insert(slot.name.clone(), (q, now_secs()));
}
if up.status == 403 {
println!(
"{}: not entitled to serve this - a lapsed subscription, most likely. Holding it out so it cannot answer for the whole fleet.",
slot.name
);
sh.unusable
.lock()
.unwrap()
.mark(&slot.name, std::time::Instant::now());
}
if up.status == 401 {
println!(
"{}: login no longer accepted - run `swapdex run {}` once to sign it in again",
slot.name, slot.name
);
sh.unusable
.lock()
.unwrap()
.mark(&slot.name, std::time::Instant::now());
}
if !ratelimit::account_cannot_serve(up.status) {
break up;
}
if up.status == 429 && ratelimit::proven_spent(&up.headers, attempt) {
let mut spent = sh.quota.lock().unwrap();
let e = spent.entry(slot.name.clone()).or_default();
e.0.rejected = true;
e.1 = now_secs();
}
if !live(paths, opts).0 || opts.account.is_some() {
refused_by = Some(slot.name.clone());
break up;
}
tried.push(slot.name.clone());
*sh.cornered.lock().unwrap() = next_account(paths, sh, &tried).is_none();
match next_account(paths, sh, &tried) {
Some(next) => {
println!(
"{} cannot serve this turn - retrying on {}",
slot.name, next.name
);
std::io::stdout().flush().ok();
*sh.rotated.lock().unwrap() = Some(next.name.clone());
drop(up); slot = next;
}
None => {
if let Some(auth) = client_auth.clone() {
println!(
"{}: no account of mine can serve this - passing your own login through",
slot.name
);
std::io::stdout().flush().ok();
drop(up);
let mut headers = client_headers.clone();
headers.push(("authorization".into(), auth));
return upstream::forward(&sh.agent, &method, &url, &headers, &client_body);
}
let names: Vec<String> = crate::slots::Slots::open(paths)
.map(|s| s.list().into_iter().map(|r| r.name).collect())
.unwrap_or_default();
let held_out = sh
.unusable
.lock()
.unwrap()
.active(std::time::Instant::now());
if held_out > 0 && held_out >= names.len().max(1) {
let first = names.first().cloned().unwrap_or_else(|| "<name>".into());
return Err(anyhow!(
"every account's login has expired. Run `swapdex run {first}` once \
(its own login refreshes there), then try again - swapdex does not \
mint tokens itself."
));
}
println!("{}: no other account can serve this turn", slot.name);
std::io::stdout().flush().ok();
refused_by = Some(slot.name.clone());
break up;
}
}
};
let mut up = up;
if up.status == 429 {
if let Some(name) = refused_by {
let tried = [name];
let somewhere = next_account(paths, sh, &tried).is_some();
if somewhere {
println!(
" another account could take this - telling the client to retry in {}s rather than let it cool down for 30 minutes",
ratelimit::CLIENT_SLEEPS_UP_TO_SECS
);
std::io::stdout().flush().ok();
}
up.headers = ratelimit::cap_retry_after(&up.headers, somewhere);
}
}
Ok(up)
}
pub fn build_id() -> String {
let stamp = std::env::current_exe()
.and_then(std::fs::metadata)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{}-{stamp}", env!("CARGO_PKG_VERSION"))
}
fn serving_file_for(paths: &Paths, tool: &str) -> std::path::PathBuf {
match tool {
"codex" => paths.store_dir().join("proxy-serving-codex"),
_ => paths.store_dir().join("proxy-serving"),
}
}
pub fn serving_account(paths: &Paths) -> Option<String> {
serving_account_for(paths, "claude-code")
}
pub fn serving_account_for(paths: &Paths, tool: &str) -> Option<String> {
running_proxy_for(paths, tool)?;
std::fs::read_to_string(serving_file_for(paths, tool))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn note_client_serving(paths: &Paths, tool: &str) {
let _ = std::fs::remove_file(serving_file_for(paths, tool));
}
fn note_serving_for(paths: &Paths, tool: &str, name: &str) {
let f = serving_file_for(paths, tool);
if std::fs::read_to_string(&f).is_ok_and(|c| c.trim() == name) {
return;
}
let _ = std::fs::write(&f, name);
}
pub fn running_port(paths: &Paths) -> Option<u16> {
running_proxy(paths).map(|(_, port, _)| port)
}
pub fn running_proxy(paths: &Paths) -> Option<(i32, u16, String)> {
running_proxy_for(paths, "claude-code")
}
pub fn tz_offset() -> i64 {
let t = now_secs() as libc::time_t;
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
if unsafe { libc::localtime_r(&t, &mut tm) }.is_null() {
return 0;
}
tm.tm_gmtoff as i64
}
fn take_the_port(paths: &Paths, tool: &str, port: u16) -> Option<tiny_http::Server> {
let (pid, held, _) = running_proxy_for(paths, tool)?;
if held != port {
return None;
}
println!(" another swapdex {tool} proxy (pid {pid}) holds {port} - taking it over");
std::io::stdout().flush().ok();
unsafe { libc::kill(pid, libc::SIGTERM) };
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(50));
if let Ok(s) = tiny_http::Server::http(("127.0.0.1", port)) {
return Some(s);
}
}
None
}
pub fn running_proxy_for(paths: &Paths, tool: &str) -> Option<(i32, u16, String)> {
let raw = std::fs::read_to_string(crate::shim::proxy_marker_for(paths, tool)).ok()?;
let mut it = raw.split_whitespace();
let pid: i32 = it.next()?.parse().ok()?;
let port: u16 = it.next()?.parse().ok()?;
let build = it.next().unwrap_or("").to_string();
(unsafe { libc::kill(pid, 0) } == 0).then_some((pid, port, build))
}
#[cfg(test)]
mod login_present_tests {
use super::*;
use crate::proxy::creds::TokenUnavailable;
#[test]
fn a_locked_keychain_is_a_signed_in_account() {
assert!(
login_present(Err(TokenUnavailable::KeychainLocked)),
"a Keychain that will not open is not an account nobody signed into"
);
assert!(
!login_present(Err(TokenUnavailable::NoLogin)),
"nothing to read is a missing login"
);
assert!(login_present(Ok(crate::secret::Secret::new(b"t".to_vec()))));
}
}
#[cfg(test)]
mod seed_tests {
use super::*;
fn entry(at: i64, five_h: f64) -> crate::quota_cache::Entry {
crate::quota_cache::Entry {
five_h: Some(five_h),
five_h_reset: Some(at + 3600),
seven_d: Some(10.0),
seven_d_reset: Some(at + 86400),
at,
on_credits: false,
}
}
#[test]
fn a_reading_taken_moments_ago_is_not_due_again_after_a_restart() {
let now = 1_800_000_000;
let mut cache = crate::quota_cache::Cache::new();
cache.insert("bsgong".into(), entry(now - 5, 87.0));
let seeded = seed_from_cache(&cache, now);
let m = seeded.get("bsgong").expect("carried over");
assert_eq!(m.five_h, Some(87.0));
assert_eq!(m.seven_d, Some(10.0));
let taken = m.taken.expect("a restored reading knows its age");
let due = taken.elapsed() >= pick::measure_after(pick::headroom(m.five_h, m.seven_d));
assert!(
!due,
"an account read 5s ago must not be asked again at once"
);
}
#[test]
fn a_stale_reading_is_still_due() {
let now = 1_800_000_000;
let mut cache = crate::quota_cache::Cache::new();
cache.insert("rnd".into(), entry(now - 3600, 87.0));
let seeded = seed_from_cache(&cache, now);
let m = seeded.get("rnd").unwrap();
let taken = m.taken.expect("age");
assert!(
taken.elapsed() >= pick::measure_after(pick::headroom(m.five_h, m.seven_d)),
"an hour-old reading is due"
);
}
#[test]
fn a_reading_stamped_ahead_of_now_is_treated_as_just_taken() {
let now = 1_800_000_000;
let mut cache = crate::quota_cache::Cache::new();
cache.insert("x".into(), entry(now + 999, 50.0));
let m = seed_from_cache(&cache, now);
let taken = m.get("x").unwrap().taken.expect("age");
assert!(
taken.elapsed() < std::time::Duration::from_secs(2),
"clamped to now"
);
}
}
#[cfg(test)]
mod tests {
use super::is_auth_exchange;
#[test]
fn only_authentication_paths_are_exempt() {
for p in [
"/v1/oauth/token",
"/oauth/authorize",
"/v1/oauth/revoke?x=1",
"/v1/OAuth/token",
"/login",
"/api/auth/callback",
] {
assert!(is_auth_exchange(p), "should be exempt: {p}");
}
for p in [
"/v1/messages",
"/v1/messages?beta=true",
"/api/hello",
"/v1/responses",
"/v1/authors",
"/v1/oauthorization-notes",
] {
assert!(!is_auth_exchange(p), "must NOT be exempt: {p}");
}
}
}
#[cfg(test)]
mod live_settings_tests {
use super::*;
#[test]
fn a_flag_wins_and_otherwise_the_setting_is_read_now() {
assert!(auto_now(Some(true), false), "--auto stands");
assert!(!auto_now(Some(false), true), "--no-auto stands");
assert!(auto_now(None, true), "no flag: follow the setting");
assert!(!auto_now(None, false));
}
}