1 import Base 2 import ./types.bend as T 3 import ./usage.bend as U 4 import ./rank.bend as R 5 import ./pick.bend as K 6 import ./bar.bend as B 7 import ./exhaust.bend as E 8 9 # One autoswitch tick: classify the trigger, honour cooldown, rank the 10 # candidates, and say why when nothing qualifies. Mirrors AutoSwitchEngine 11 # ._tick_inner of claude_swap 0.27.2 with patch 0002. 12 # 13 # Not decided here (the CLI owns them): freshening the target, quarantining a 14 # target whose token is dead, the consume-first re-fetch (`stale-usage`), 15 # `no-viable-target` and `already-active`. To walk the reference's ordered 16 # list, re-run `decide` with a failed target marked `disabled`. 17 18 # What a tick reads. 19 type Env is Data: 20 Env{active: Nat, as: List<&2, T.Account>, us: List<&2, T.Usage>, now: Nat, cfg: T.Config, st: T.State, mem: T.Mem} 21 22 def cfg.threshold(+cfg: T.Config) -> Nat: 23 match cfg: 24 case T.Config{thr, ms, st, iv, cd, hy, ttl, ut, ia}: 25 thr 26 27 def cfg.models(+cfg: T.Config) -> List<&2, String>: 28 match cfg: 29 case T.Config{thr, ms, st, iv, cd, hy, ttl, ut, ia}: 30 ms 31 32 def cfg.include(+cfg: T.Config) -> Bool: 33 match cfg: 34 case T.Config{thr, ms, st, iv, cd, hy, ttl, ut, ia}: 35 ia 36 37 def cfg.cf(+cfg: T.Config) -> Bool: 38 match cfg: 39 case T.Config{thr, ms, st, iv, cd, hy, ttl, ut, ia}: 40 K.strat.cf(st) 41 42 def in_cooldown(+last: Maybe<&2, Nat>, +now: Nat, +cd: Nat) -> Bool: 43 match last: 44 case None{}: 45 False{} 46 case Some{l}: 47 Nat.is_lt(Nat.sub(now, l), cd) 48 49 def cfg.cooldown(+cfg: T.Config) -> Nat: 50 match cfg: 51 case T.Config{thr, ms, st, iv, cd, hy, ttl, ut, ia}: 52 cd 53 54 def st.last_at(+st: T.State) -> Maybe<&2, Nat>: 55 match st: 56 case T.State{la, lt, lf, sn}: 57 la 58 59 # ------------------------------------------------ the idle hold, in memory 60 61 def is_expired(+s: T.Sentinel) -> Bool: 62 match s: 63 case T.TokenExpired{}: 64 True{} 65 case o: 66 False{} 67 68 def row.expired(+m: Maybe<&2, T.Usage>) -> Bool: 69 match m: 70 case None{}: 71 False{} 72 case Some{u}: 73 is_expired(U.usage.sentinel(u)) 74 75 def idle.since(+mem: T.Mem, +now: Nat) -> Nat: 76 match mem: 77 case T.Mem{un, isn}: 78 match isn: 79 case None{}: 80 now 81 case Some{t}: 82 t 83 84 def mem.unhealthy(+mem: T.Mem) -> Nat: 85 match mem: 86 case T.Mem{un, isn}: 87 un 88 89 # The idle hold lasts 30 minutes from the first expired tick. 90 def idle.held(+mem: T.Mem, +now: Nat) -> Bool: 91 Nat.is_le(Nat.sub(now, idle.since(mem, now)), 1800n) 92 93 def mem.expired(+mem: T.Mem, +now: Nat, +ut: Nat) -> T.Mem: 94 T.Mem{Nat.add(mem.unhealthy(mem), 1n), Some{idle.since(mem, now)}} 95 96 def mem.hold(+held: Bool, +mem: T.Mem, +now: Nat, +ut: Nat) -> T.Mem: 97 match held: 98 case True{}: 99 T.Mem{0n, Some{idle.since(mem, now)}} 100 case False{}: 101 mem.expired(mem, now, ut) 102 103 # Counters after a tick whose active headroom is unknown. 104 def mem.unknown(+expired: Bool, +mem: T.Mem, +now: Nat, +ut: Nat) -> T.Mem: 105 match expired: 106 case False{}: 107 T.fresh_mem() 108 case True{}: 109 mem.hold(idle.held(mem, now), mem, now, ut) 110 111 def mem.known(+ah: Maybe<&2, Nat>, +expired: Bool, +mem: T.Mem, +now: Nat, +ut: Nat) -> T.Mem: 112 match ah: 113 case Some{a}: 114 T.fresh_mem() 115 case None{}: 116 mem.unknown(expired, mem, now, ut) 117 118 def mem.ut(+cfg: T.Config) -> Nat: 119 match cfg: 120 case T.Config{thr, ms, st, iv, cd, hy, ttl, ut, ia}: 121 ut 122 123 def mem.run(+active: Nat, +us: List<&2, T.Usage>, +now: Nat, +cfg: T.Config, +mem: T.Mem) -> T.Mem: 124 mem.known(U.head.row(U.usage.of(active, us), now, cfg.models(cfg)), row.expired(U.usage.of(active, us)), mem, now, mem.ut(cfg)) 125 126 def mem.api(+include: Bool, +active: Nat, +us: List<&2, T.Usage>, +now: Nat, +cfg: T.Config, +mem: T.Mem) -> T.Mem: 127 match include: 128 case False{}: 129 mem 130 case True{}: 131 mem.run(active, us, now, cfg, mem) 132 133 def mem.kind(+k: T.AcctKind, +include: Bool, +active: Nat, +us: List<&2, T.Usage>, +now: Nat, +cfg: T.Config, +mem: T.Mem) -> T.Mem: 134 match k: 135 case T.ApiKey{}: 136 mem.api(include, active, us, now, cfg, mem) 137 case o: 138 mem.run(active, us, now, cfg, mem) 139 140 def mem.acct(+m: Maybe<&2, T.Account>, +active: Nat, +us: List<&2, T.Usage>, +now: Nat, +cfg: T.Config, +mem: T.Mem) -> T.Mem: 141 match m: 142 case None{}: 143 mem 144 case Some{a}: 145 match a: 146 case T.Account{slot, email, kind, disabled}: 147 mem.kind(kind, cfg.include(cfg), active, us, now, cfg, mem) 148 149 # The engine's counters after this tick: a known headroom or an unexpired- 150 # sentinel unknown resets them; an expired token starts the idle hold and, 151 # once it ran out, counts unhealthy ticks. Unmanaged and API-key actives 152 # return before touching them. 153 def mem_after(+active: Nat, +accounts: List<&2, T.Account>, +usage: List<&2, T.Usage>, +now: Nat, +cfg: T.Config, +mem: T.Mem) -> T.Mem: 154 mem.acct(U.account.of(active, accounts), active, usage, now, cfg, mem) 155 156 # ------------------------------------------------------- why nothing qualified 157 158 def none.exh.at(+ex: Bool, +e: E.Er) -> T.Decision: 159 match ex: 160 case True{}: 161 T.NoSwitch{T.AllExhausted{E.er.out(e)}, ""} 162 case False{}: 163 T.NoSwitch{T.NoQualifyingCandidate{}, "no candidate is below the threshold and better than the active account by the hysteresis margin, or usage is unreadable this tick"} 164 165 def none.reset(+ars: Maybe<&2, Nat>) -> T.Decision: 166 match ars: 167 case None{}: 168 T.NoSwitch{T.ResetUnknown{}, "active account's weekly reset time is unknown; consume-first is idle until it is reported"} 169 case Some{r}: 170 T.NoSwitch{T.AlreadyConsumingSoonest{}, "no sooner-resetting account with room to spare"} 171 172 def none.trig(+t: T.Trigger, +ex: Bool, +ars: Maybe<&2, Nat>, +as: List<&2, T.Account>, +us: List<&2, T.Usage>, +now: Nat, +ms: List<&2, String>) -> T.Decision: 173 match t: 174 case T.ConsumeFirstT{}: 175 none.reset(ars) 176 case o: 177 none.exh.at(ex, E.earliest(as, us, now, ms, E.ErNone{})) 178 179 def none.known(+k: Bool, +t: T.Trigger, +ex: Bool, +ars: Maybe<&2, Nat>, +as: List<&2, T.Account>, +us: List<&2, T.Usage>, +now: Nat, +ms: List<&2, String>) -> T.Decision: 180 match k: 181 case False{}: 182 T.NoSwitch{T.NoComparison{}, "no candidate has readable usage"} 183 case True{}: 184 none.trig(t, ex, ars, as, us, now, ms) 185 186 def none.cf(+t: T.Trigger, +ars: Maybe<&2, Nat>, +as: List<&2, T.Account>, +active: Nat, +us: List<&2, T.Usage>, +now: Nat, +ms: List<&2, String>) -> T.Decision: 187 none.known(E.any_known(as, active, us, now, ms), t, E.all_exhausted(as, active, us, now, ms), ars, as, us, now, ms) 188 189 def trig.not_cf(+t: T.Trigger) -> Bool: 190 match t: 191 case T.ConsumeFirstT{}: 192 False{} 193 case o: 194 True{} 195 196 def api.pick(+m: Maybe<&2, Nat>, +t: T.Trigger, +next: T.Decision) -> T.Decision: 197 match m: 198 case Some{slot}: 199 T.Switch{slot, t, T.trigger.name(t), 0n, T.MApiLast{}, None{}, None{}} 200 case None{}: 201 next 202 203 def api.gate(+ok: Bool, +t: T.Trigger, +as: List<&2, T.Account>, +active: Nat, +next: T.Decision) -> T.Decision: 204 match ok: 205 case True{}: 206 api.pick(E.api.first(as, active), t, next) 207 case False{}: 208 next 209 210 # ------------------------------------------------------------ ranking 211 212 def dec.sw(+p: K.Pk, +t: T.Trigger, +bar: Maybe<&2, Nat>, +next: T.Decision) -> T.Decision: 213 match p: 214 case K.PkAt{slot, r, h, mode}: 215 T.Switch{slot, t, T.trigger.name(t), h, mode, r, bar} 216 case K.PkNone{}: 217 next 218 219 def meas.best(+m: R.Meas) -> Nat: 220 match m: 221 case R.Meas{any, all, best}: 222 best 223 224 def rank.ctx(+t: T.Trigger, +ah: Maybe<&2, Nat>, +m: R.Meas, +ar: Maybe<&2, Nat>, +ars: Maybe<&2, Nat>, +active: Nat, +us: List<&2, T.Usage>, +now: Nat, +cfg: T.Config) -> K.Rk: 225 match cfg: 226 case T.Config{thr, ms, stg, iv, cd, hy, ttl, ut, ia}: 227 K.rk.mk(t, ah, R.all_above(m, ah, thr), meas.best(m), ar, ars, thr, hy, K.strat.cf(stg), active, None{}, us, now, ms) 228 229 # The ranking context of a tick, before the bar. 230 def rk.base(+t: T.Trigger, +ah: Maybe<&2, Nat>, +active: Nat, +as: List<&2, T.Account>, +us: List<&2, T.Usage>, +now: Nat, +cfg: T.Config) -> K.Rk: 231 rank.ctx(t, ah, R.meas(as, active, us, now, cfg.models(cfg), cfg.threshold(cfg), R.Meas{False{}, True{}, 0n}), R.rec.row(U.usage.of(active, us), now, cfg.models(cfg)), R.seven.row(U.usage.of(active, us), now, cfg.models(cfg)), active, us, now, cfg) 232 233 # --------------------------------------------------- candidates and cooldown 234 235 def below.detail(+a: Nat, +cfg: T.Config) -> String: 236 U.pct.label(Nat.sub(T.full(), a)) ++ "% < " ++ U.pct.label(cfg.threshold(cfg)) ++ "%" 237 238 # The pick for one ranking context, as a decision: the target, or `next`. 239 def sw.pick(+rk: K.Rk, +as: List<&2, T.Account>, +t: T.Trigger, +next: T.Decision) -> T.Decision: 240 dec.sw(K.pick(as, rk), t, K.rk.bar_of(rk), next) 241 242 # Rank with the bar; if that leaves nothing and the barred account has 243 # recovered, rank again without it. 244 def choose.retry(+go: Bool, +t: T.Trigger, +as: List<&2, T.Account>, +rk0: K.Rk, +next: T.Decision) -> T.Decision: 245 match go: 246 case True{}: 247 sw.pick(K.rk.bar(rk0, None{}), as, t, next) 248 case False{}: 249 next 250 251 def env.active(+e: Env) -> Nat: 252 match e: 253 case Env{active, as, us, now, cfg, st, mem}: 254 active 255 256 def env.as(+e: Env) -> List<&2, T.Account>: 257 match e: 258 case Env{active, as, us, now, cfg, st, mem}: 259 as 260 261 def env.cfg(+e: Env) -> T.Config: 262 match e: 263 case Env{active, as, us, now, cfg, st, mem}: 264 cfg 265 266 def trig.is_cf(+t: T.Trigger) -> Bool: 267 Bool.not(trig.not_cf(t)) 268 269 def env.now(+e: Env) -> Nat: 270 match e: 271 case Env{active, as, us, now, cfg, st, mem}: 272 now 273 274 def env.st(+e: Env) -> T.State: 275 match e: 276 case Env{active, as, us, now, cfg, st, mem}: 277 st 278 279 # ------------------------------------------------------ the active account 280 281 def env.us(+e: Env) -> List<&2, T.Usage>: 282 match e: 283 case Env{active, as, us, now, cfg, st, mem}: 284 us 285 286 def env.mem(+e: Env) -> T.Mem: 287 match e: 288 case Env{active, as, us, now, cfg, st, mem}: 289 mem 290 291 def trig.of(+a: Nat) -> T.Trigger: 292 match a: 293 case 0n: 294 T.AtLimit{} 295 case 1n+p: 296 T.Proactive{} 297 298 # The active account's headroom, as this tick reads it. 299 def env.ah(+e: Env) -> Maybe<&2, Nat>: 300 match e: 301 case Env{active, as, us, now, cfg, st, mem}: 302 U.head.row(U.usage.of(active, us), now, cfg.models(cfg)) 303 304 # No OAuth candidate qualified: last-resort API-key account, else say why. 305 def decide.none(+t: T.Trigger, +ars: Maybe<&2, Nat>, +as: List<&2, T.Account>, +active: Nat, +us: List<&2, T.Usage>, +now: Nat, +cfg: T.Config) -> T.Decision: 306 match cfg: 307 case T.Config{thr, ms, stg, iv, cd, hy, ttl, ut, ia}: 308 api.gate(ia && trig.not_cf(t), t, as, active, none.cf(t, ars, as, active, us, now, ms)) 309 310 def rank.bar(+bar: Maybe<&2, Nat>, +recov: Bool, +t: T.Trigger, +e: Env) -> T.Decision: 311 match e: 312 case Env{active, as, us, now, cfg, st, mem}: 313 sw.pick(K.rk.bar(rk.base(t, env.ah(e), active, as, us, now, cfg), bar), as, t, choose.retry(Bool.not(B.is_none(bar)) && recov, t, as, rk.base(t, env.ah(e), active, as, us, now, cfg), decide.none(t, R.seven.row(U.usage.of(active, us), now, cfg.models(cfg)), as, active, us, now, cfg))) 314 315 def rank.go(+t: T.Trigger, +e: Env) -> T.Decision: 316 match e: 317 case Env{active, as, us, now, cfg, st, mem}: 318 rank.bar(B.no_return(t, st, active, B.recovered(st, active, us, env.ah(e), cfg.threshold(cfg), now, cfg.models(cfg)), us, env.ah(e), cfg.threshold(cfg), now, cfg.models(cfg)), B.recovered(st, active, us, env.ah(e), cfg.threshold(cfg), now, cfg.models(cfg)), t, e) 319 320 def cands.none(+blocked: Bool, +t: T.Trigger, +e: Env) -> T.Decision: 321 match blocked: 322 case True{}: 323 T.NoSwitch{T.NoCandidates{}, ""} 324 case False{}: 325 rank.go(t, e) 326 327 # A healthy consume-first account with no OAuth peer to compare against is 328 # below the threshold, exactly as under `best`. 329 def cands.cf(+hold: Bool, +t: T.Trigger, +e: Env) -> T.Decision: 330 match hold: 331 case True{}: 332 T.NoSwitch{T.BelowThreshold{}, below.detail(K.ah.or0(env.ah(e)), env.cfg(e))} 333 case False{}: 334 cands.none(Bool.not(E.any_cand(env.as(e), env.active(e))) && Bool.not(cfg.include(env.cfg(e)) && E.any_api(env.as(e), env.active(e))), t, e) 335 336 def decide.cands(+t: T.Trigger, +e: Env) -> T.Decision: 337 cands.cf(trig.is_cf(t) && Bool.not(E.any_cand(env.as(e), env.active(e))) && Bool.not(B.is_none(env.ah(e))), t, e) 338 339 def cool.go(+cool: Bool, +t: T.Trigger, +e: Env) -> T.Decision: 340 match cool: 341 case True{}: 342 T.NoSwitch{T.Cooldown{}, ""} 343 case False{}: 344 decide.cands(t, e) 345 346 # Cooldown bounds proactive and consume-first switches; at-limit and 347 # failover ignore it. 348 def decide.trig(+t: T.Trigger, +e: Env) -> T.Decision: 349 match t: 350 case T.Proactive{}: 351 cool.go(in_cooldown(st.last_at(env.st(e)), env.now(e), cfg.cooldown(env.cfg(e))), t, e) 352 case T.ConsumeFirstT{}: 353 cool.go(in_cooldown(st.last_at(env.st(e)), env.now(e), cfg.cooldown(env.cfg(e))), t, e) 354 case o: 355 decide.cands(t, e) 356 357 def fail.count(+wait: Bool, +n: Nat, +ut: Nat, +e: Env) -> T.Decision: 358 match wait: 359 case True{}: 360 T.NoSwitch{T.ActiveUsageUnknown{}, Nat.show(n) ++ "/" ++ Nat.show(ut) ++ " before failover"} 361 case False{}: 362 decide.trig(T.Failover{}, e) 363 364 def failover.go(+n: Nat, +e: Env) -> T.Decision: 365 match e: 366 case Env{active, as, us, now, cfg, st, mem}: 367 fail.count(Nat.is_lt(n, mem.ut(cfg)), n, mem.ut(cfg), e) 368 369 def idle.go(+held: Bool, +e: Env) -> T.Decision: 370 match held: 371 case True{}: 372 T.NoSwitch{T.ActiveIdle{}, "token expired while Claude Code is idle; resumes on next use"} 373 case False{}: 374 failover.go(Nat.add(mem.unhealthy(env.mem(e)), 1n), e) 375 376 def unknown.go(+expired: Bool, +e: Env) -> T.Decision: 377 match expired: 378 case False{}: 379 T.NoSwitch{T.ActiveUsageUnknown{}, "headroom unknown; not counted toward failover"} 380 case True{}: 381 idle.go(idle.held(env.mem(e), env.now(e)), e) 382 383 def below.go(+cf: Bool, +a: Nat, +e: Env) -> T.Decision: 384 match cf: 385 case True{}: 386 decide.trig(T.ConsumeFirstT{}, e) 387 case False{}: 388 T.NoSwitch{T.BelowThreshold{}, below.detail(a, env.cfg(e))} 389 390 def util.go(+below: Bool, +a: Nat, +e: Env) -> T.Decision: 391 match below: 392 case True{}: 393 below.go(cfg.cf(env.cfg(e)), a, e) 394 case False{}: 395 decide.trig(trig.of(a), e) 396 397 def decide.known(+ah: Maybe<&2, Nat>, +e: Env) -> T.Decision: 398 match ah: 399 case Some{a}: 400 util.go(Nat.is_lt(Nat.sub(T.full(), a), cfg.threshold(env.cfg(e))), a, e) 401 case None{}: 402 unknown.go(row.expired(U.usage.of(env.active(e), env.us(e))), e) 403 404 def decide.run(+e: Env) -> T.Decision: 405 decide.known(U.head.row(U.usage.of(env.active(e), env.us(e)), env.now(e), cfg.models(env.cfg(e))), e) 406 407 def apikey.go(+include: Bool, +e: Env) -> T.Decision: 408 match include: 409 case True{}: 410 decide.run(e) 411 case False{}: 412 T.NoSwitch{T.ActiveApiKey{}, "API-key accounts have no quota to watch"} 413 414 def decide.kind(+k: T.AcctKind, +e: Env) -> T.Decision: 415 match k: 416 case T.ApiKey{}: 417 apikey.go(cfg.include(env.cfg(e)), e) 418 case o: 419 decide.run(e) 420 421 def decide.acct(+m: Maybe<&2, T.Account>, +e: Env) -> T.Decision: 422 match m: 423 case None{}: 424 T.NoSwitch{T.UnmanagedActive{}, "run 'cswap --add-account' to include it in rotation"} 425 case Some{a}: 426 match a: 427 case T.Account{slot, email, kind, disabled}: 428 decide.kind(kind, e) 429 430 # One autoswitch tick. `st` is autoswitch_state.json (last switch, where it 431 # came from, what that account looked like), `mem` the engine's counters 432 # (see `mem_after` for the next ones). Pure: the caller fetches usage, 433 # applies the decision and records the switch. 434 def decide(+active: Nat, +accounts: List<&2, T.Account>, +usage: List<&2, T.Usage>, +now: Nat, +cfg: T.Config, +st: T.State, +mem: T.Mem) -> T.Decision: 435 decide.acct(U.account.of(active, accounts), Env{active, accounts, usage, now, cfg, st, mem})