policy/types.bend

cswap-bend-cli@0.1.0.0 · 201 lines · 26 definitions

1import 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<Nat>` 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
13type AcctKind is Data:
14  SetupToken{}
15  FullLogin{}
16  ApiKey{}
17
18type Account is Data:
19  Account{slot: Nat, email: String, kind: AcctKind, disabled: Bool}
20
21# A per-model scoped window (Opus, Fable, ...).
22type Scoped is Data:
23  Scoped{name: String, pct: Nat, reset: Maybe<&2, Nat>}
24
25type 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.
34def failing(+e: ErrKind) -> Nat:
35  match e:
36    case ErrNone{}:
37      0n
38    case o:
39      1n
40
41type 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.
48type 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.
52type Win is Data:
53  Win{pct: Nat, reset: Maybe<&2, Nat>}
54
55type Strategy is Data:
56  Best{}
57  ConsumeFirst{}
58
59type 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
72type 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.
81type Snap is Data:
82  NoSnap{}
83  Snap{headroom: Maybe<&2, Nat>, recovery: Maybe<&2, Nat>, trigger: Maybe<&2, Trigger>}
84
85type 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.
90type Mem is Data:
91  Mem{unhealthy: Nat, idle_since: Maybe<&2, Nat>}
92
93type 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`).
111type 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.
121type 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
125def full() -> Nat:
126  10000n
127
128def default_config() -> Config:
129  Config{9000n, Nil{}, Best{}, 60n, 300n, 1000n, 180n, 3n, False{}}
130
131def fresh_state() -> State:
132  State{None{}, None{}, None{}, NoSnap{}}
133
134def fresh_mem() -> Mem:
135  Mem{0n, None{}}
136
137# ------------------------------------------------- names (JSON reason values)
138
139def 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
156def 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
171def 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.
183def 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
196def 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