1 import Base 2 3 # Data types of the pure decision core, and the JSON names the CLI emits. 4 # 5 # Units: percentages are centi-percent Nat (100 % = 10000, so 85.5 % = 8550); 6 # times are unix seconds as Nat; a Nat of 0 in `backoff_until`, `held_until` 7 # and `claim_until` means "not set"; a `Maybe` reset that is None is the 8 # reference's `inf` ("unknown or already past"). 9 # 10 # Reference: claude_swap 0.27.2 autoswitch.py / poll_policy.py / usage_store.py 11 # / oauth.py, with patch 0002 (unknown headroom never counts toward failover). 12 13 type AcctKind is Data: 14 SetupToken{} 15 FullLogin{} 16 ApiKey{} 17 18 type Account is Data: 19 Account{slot: Nat, email: String, kind: AcctKind, disabled: Bool} 20 21 # A per-model scoped window (Opus, Fable, ...). 22 type Scoped is Data: 23 Scoped{name: String, pct: Nat, reset: Maybe<&2, Nat>} 24 25 type ErrKind is Data: 26 ErrNone{} 27 ErrHttp429{} 28 ErrOther{} 29 30 # A derived state laid over a stored row at read time. `TokenExpired` is the 31 # idle-Claude-Code sentinel; every sentinel hides the row's windows. 32 # 0 when the last fetch left no error, else 1: how a row built without a 33 # stored failure count says whether it is failing. 34 def failing(+e: ErrKind) -> Nat: 35 match e: 36 case ErrNone{}: 37 0n 38 case o: 39 1n 40 41 type Sentinel is Data: 42 NoSentinel{} 43 TokenExpired{} 44 OtherSentinel{} 45 46 # One account's usage row. `five`/`seven` are None when the API sent no such 47 # window. `fetched_at` is None before the first success. 48 type Usage is Data: 49 Usage{slot: Nat, five: Maybe<&2, Nat>, five_reset: Maybe<&2, Nat>, seven: Maybe<&2, Nat>, seven_reset: Maybe<&2, Nat>, scoped: List<&2, Scoped>, fetched_at: Maybe<&2, Nat>, backoff_until: Nat, last_error: ErrKind, failures: Nat, next_poll_at: Maybe<&2, Nat>, poll_interval: Nat, auth_dead: Nat, held_until: Nat, claim_until: Nat, sentinel: Sentinel} 50 51 # One relevant window: the utilization and, if the API sent one, its reset. 52 type Win is Data: 53 Win{pct: Nat, reset: Maybe<&2, Nat>} 54 55 type Strategy is Data: 56 Best{} 57 ConsumeFirst{} 58 59 type Config is Data: 60 Config{ 61 threshold: Nat, 62 models: List<&2, String>, 63 strategy: Strategy, 64 interval: Nat, 65 cooldown: Nat, 66 hysteresis: Nat, 67 serve_ttl: Nat, 68 unhealthy_ticks: Nat, 69 include_api: Bool 70 } 71 72 type Trigger is Data: 73 Proactive{} 74 AtLimit{} 75 Failover{} 76 ConsumeFirstT{} 77 78 # What the last switch recorded (autoswitch_state.json): the headroom and 79 # binding recovery of the account it left, and the trigger. `NoSnap` is a 80 # record written before the snapshot existed. 81 type Snap is Data: 82 NoSnap{} 83 Snap{headroom: Maybe<&2, Nat>, recovery: Maybe<&2, Nat>, trigger: Maybe<&2, Trigger>} 84 85 type State is Data: 86 State{last_at: Maybe<&2, Nat>, last_to: Maybe<&2, Nat>, last_from: Maybe<&2, Nat>, snap: Snap} 87 88 # The engine's in-memory counters: consecutive unhealthy ticks, and when the 89 # idle hold for an expired token began. `Mem{0n, None{}}` is a fresh process. 90 type Mem is Data: 91 Mem{unhealthy: Nat, idle_since: Maybe<&2, Nat>} 92 93 type Reason is Data: 94 UnmanagedActive{} 95 ActiveApiKey{} 96 BelowThreshold{} 97 ActiveIdle{} 98 ActiveUsageUnknown{} 99 Cooldown{} 100 NoCandidates{} 101 NoComparison{} 102 ResetUnknown{} 103 AlreadyConsumingSoonest{} 104 NoQualifyingCandidate{} 105 AllExhausted{earliest: Maybe<&2, Nat>} 106 107 # How the target was ranked: by headroom (`MHead`), by weekly reset 108 # (consume-first, `MReset`), by recovery (all accounts above the threshold: 109 # `MRec0` back soon, `MRec1` back late, `MFallback` the one-way fallback), or 110 # as the last-resort API-key account (`MApiLast`). 111 type Mode is Data: 112 MHead{} 113 MReset{} 114 MRec0{} 115 MRec1{} 116 MFallback{} 117 MApiLast{} 118 119 # `Switch.rec` is the winner's ranking time (reset or recovery) when the mode 120 # ranks by one; `bar` is the no-return bar that applied to this pick. 121 type Decision is Data: 122 NoSwitch{reason: Reason, detail: String} 123 Switch{to: Nat, trigger: Trigger, reason: String, headroom: Nat, mode: Mode, rec: Maybe<&2, Nat>, bar: Maybe<&2, Nat>} 124 125 def full() -> Nat: 126 10000n 127 128 def default_config() -> Config: 129 Config{9000n, Nil{}, Best{}, 60n, 300n, 1000n, 180n, 3n, False{}} 130 131 def fresh_state() -> State: 132 State{None{}, None{}, None{}, NoSnap{}} 133 134 def fresh_mem() -> Mem: 135 Mem{0n, None{}} 136 137 # ------------------------------------------------- names (JSON reason values) 138 139 def reason.name.more(+r: Reason) -> String: 140 match r: 141 case Cooldown{}: 142 "cooldown" 143 case NoCandidates{}: 144 "no-candidates" 145 case NoComparison{}: 146 "no-comparison" 147 case ResetUnknown{}: 148 "reset-unknown" 149 case AlreadyConsumingSoonest{}: 150 "already-consuming-soonest" 151 case NoQualifyingCandidate{}: 152 "no-qualifying-candidate" 153 case o: 154 "all-exhausted" 155 156 def reason.name(+r: Reason) -> String: 157 match r: 158 case UnmanagedActive{}: 159 "unmanaged-active-account" 160 case ActiveApiKey{}: 161 "active-api-key" 162 case BelowThreshold{}: 163 "below-threshold" 164 case ActiveIdle{}: 165 "active-idle" 166 case ActiveUsageUnknown{}: 167 "active-usage-unknown" 168 case o: 169 reason.name.more(o) 170 171 def trigger.name(+t: Trigger) -> String: 172 match t: 173 case Proactive{}: 174 "proactive" 175 case AtLimit{}: 176 "at-limit" 177 case Failover{}: 178 "failover" 179 case ConsumeFirstT{}: 180 "consume-first" 181 182 # Exit codes of `auto --once`: 0 switched, 2 no action, 3 blocked. 183 def reason.exit(+r: Reason) -> U32: 184 match r: 185 case NoCandidates{}: 186 3 187 case NoComparison{}: 188 3 189 case NoQualifyingCandidate{}: 190 3 191 case AllExhausted{e}: 192 3 193 case o: 194 2 195 196 def exit_code(+d: Decision) -> U32: 197 match d: 198 case NoSwitch{r, detail}: 199 reason.exit(r) 200 case Switch{to, t, why, h, mode, rec, bar}: 201 0