daemon/config.bend

cswap-bend-cli@0.1.0.0 · 407 lines · 69 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/json.bend as Json
3import cswap-bend-cli@0.1.0.0/fmt/code.bend as Code
4
5# The autoswitch settings (the `autoswitch` block of settings.json, loaded the
6# lenient way of settings.py) and the flags of `cswap auto`, which override
7# them. Percentages are centi-percent U32 (90 % = 9000); times are whole seconds.
8
9type Cfg is Data:
10  Cfg{threshold: U32, interval: U32, cooldown: U32, hysteresis: U32, strategy: String, include_api: Bool, unhealthy: U32, models: List<&2, String>}
11
12type Flags is Data:
13  Flags{once: Bool, fmt: U32, dry_run: Bool, threshold: Maybe<&2, U32>, interval: Maybe<&2, U32>, cooldown: Maybe<&2, U32>, strategy: Maybe<&2, String>, models: Maybe<&2, String>, include_api: Bool}
14
15def defaults() -> Cfg:
16  Cfg{9000, 60, 300, 1000, "best", False{}, 3, []}
17
18def sel_u(+c: Bool, +x: U32, +y: U32) -> U32:
19  match c:
20    case True{}:
21      x
22    case False{}:
23      y
24
25def clamp(+v: U32, +lo: U32, +hi: U32) -> U32:
26  sel_u(U32.is_lt(v, lo), lo, sel_u(U32.is_gt(v, hi), hi, v))
27
28# ---- numbers ----------------------------------------------------------------------------------
29
30def digits(+s: String) -> Bool:
31  match s:
32    case SNil{}:
33      True{}
34    case SCon{c, t}:
35      Char.is_digit(c) && digits(t)
36
37def read_u32b(+ok: Bool, +s: String) -> Maybe<&2, U32>:
38  match ok:
39    case True{}:
40      U32.read(s)
41    case False{}:
42      None{}
43
44def read_u32(+s: String) -> Maybe<&2, U32>:
45  read_u32b(digits(s) && Bool.not(String.is_empty(s)), s)
46
47def cents2(+i: Maybe<&2, U32>, +f: Maybe<&2, U32>) -> Maybe<&2, U32>:
48  match i f:
49    case Some{a} Some{b}:
50      Some{(a * 100 + b : U32)}
51    case _ _:
52      None{}
53
54def centi2(+parts: List<&2, String>) -> Maybe<&2, U32>:
55  match parts:
56    case Con{i, Nil{}}:
57      cents2(read_u32(i), Some{0})
58    case Con{i, Con{f, Nil{}}}:
59      cents2(read_u32(i), read_u32(String.take(f ++ "00", 2n)))
60    case _:
61      None{}
62
63# "12.34" -> 1234, "7" -> 700, "5.5" -> 550; None for anything else (signs,
64# exponents, text). Digits past the second decimal are dropped.
65def centi(+t: String) -> Maybe<&2, U32>:
66  centi2(String.split(t, '.'))
67
68def num_text(+m: Maybe<&2, Json.Json>) -> Maybe<&2, String>:
69  match m:
70    case Some{Json.JNum{t}}:
71      Some{t}
72    case _:
73      None{}
74
75def centi_of(+m: Maybe<&2, String>) -> Maybe<&2, U32>:
76  match m:
77    case Some{t}:
78      centi(t)
79    case None{}:
80      None{}
81
82def field_centi2(+m: Maybe<&2, U32>, +d: U32, +lo: U32, +hi: U32) -> U32:
83  match m:
84    case Some{v}:
85      clamp(v, lo, hi)
86    case None{}:
87      d
88
89# A numeric settings field, centi, clamped; the default when absent or not a number.
90def field_centi(+j: Json.Json, +key: String, +d: U32, +lo: U32, +hi: U32) -> U32:
91  field_centi2(centi_of(num_text(Json.get(key, j))), d, lo, hi)
92
93# Whole seconds from a centi field (the fraction is dropped).
94def field_secs(+j: Json.Json, +key: String, +d: U32, +lo: U32, +hi: U32) -> U32:
95  (field_centi(j, key, (d * 100 : U32), (lo * 100 : U32), (hi * 100 : U32)) / 100 : U32)
96
97# A bool field is read by truthiness: the string "false" is true.
98def truthy(+m: Maybe<&2, Json.Json>, +d: Bool) -> Bool:
99  match m:
100    case Some{Json.JBool{b}}:
101      b
102    case Some{Json.JNull{}}:
103      False{}
104    case Some{Json.JStr{s}}:
105      Bool.not(String.is_empty(s))
106    case Some{Json.JNum{t}}:
107      Bool.not(String.eq(t, "0") || String.eq(t, "0.0"))
108    case Some{j}:
109      True{}
110    case None{}:
111      d
112
113def pick_strategy(+ok: Bool, +s: String, +d: String) -> String:
114  match ok:
115    case True{}:
116      s
117    case False{}:
118      d
119
120# ---- model names: comma list, trimmed, de-duplicated without regard to case ---------------------
121
122def strategy_of(+m: Maybe<&2, Json.Json>, +d: String) -> String:
123  match m:
124    case Some{Json.JStr{s}}:
125      pick_strategy(String.eq(s, "best") || String.eq(s, "consume-first"), s, d)
126    case _:
127      d
128
129def has_ci(+x: String, +xs: List<&2, String>) -> Bool:
130  match xs:
131    case Nil{}:
132      False{}
133    case Con{h, t}:
134      String.eq(String.to_lower(h), String.to_lower(x)) || has_ci(x, t)
135
136def keep_name(+h: String, +seen: List<&2, String>) -> Bool:
137  Bool.not(String.is_empty(h)) && Bool.not(has_ci(h, seen))
138
139def seen_after(+keep: Bool, +h: String, +seen: List<&2, String>) -> List<&2, String>:
140  match keep:
141    case True{}:
142      List.append(&2, String, seen, [h])
143    case False{}:
144      seen
145
146def out_name(+keep: Bool, +h: String, +rest: List<&2, String>) -> List<&2, String>:
147  match keep:
148    case True{}:
149      h <> rest
150    case False{}:
151      rest
152
153def dedupe(+xs: List<&2, String>, +seen: List<&2, String>) -> List<&2, String>:
154  match xs:
155    case Nil{}:
156      []
157    case Con{h, t}:
158      out_name(keep_name(h, seen), h, dedupe(t, seen_after(keep_name(h, seen), h, seen)))
159
160def trim_all(+xs: List<&2, String>) -> List<&2, String>:
161  match xs:
162    case Nil{}:
163      []
164    case Con{h, t}:
165      String.trim(h) <> trim_all(t)
166
167def parse_models(+s: String) -> List<&2, String>:
168  dedupe(trim_all(String.split(s, ',')), [])
169
170def models_of(+m: Maybe<&2, Json.Json>) -> List<&2, String>:
171  match m:
172    case Some{Json.JStr{s}}:
173      parse_models(s)
174    case _:
175      []
176
177# ---- settings.json ------------------------------------------------------------------------------
178
179def block_of(+m: Maybe<&2, Json.Json>) -> Json.Json:
180  match m:
181    case Some{b}:
182      b
183    case None{}:
184      Json.JNull{}
185
186def autoswitch_block(+j: Json.Json) -> Json.Json:
187  block_of(Json.get("autoswitch", j))
188
189def from_block(+b: Json.Json) -> Cfg:
190  Cfg{field_centi(b, "threshold", 9000, 5000, 9990), field_secs(b, "intervalSeconds", 60, 15, 3600), field_secs(b, "cooldownSeconds", 300, 0, 86400),
191    field_centi(b, "hysteresisPct", 1000, 0, 5000), strategy_of(Json.get("strategy", b), "best"), truthy(Json.get("includeApiKeyAccounts", b), False{}),
192    (field_centi(b, "unhealthyTicks", 300, 100, 10000) / 100 : U32), models_of(Json.get("model", b))}
193
194# The lenient load: anything unreadable is the default, numbers are clamped.
195def from_settings(+j: Json.Json) -> Cfg:
196  from_block(autoswitch_block(j))
197
198def from_result(r: Result<&2, &2, String, Json.Json>) -> Cfg:
199  match r:
200    case Done{j}:
201      from_settings(j)
202    case Fail{e}:
203      defaults()
204
205# ---- flags --------------------------------------------------------------------------------------
206
207def from_text(+t: String) -> Cfg:
208  from_result(Json.parse(t))
209
210def no_flags() -> Flags:
211  Flags{False{}, 0, False{}, None{}, None{}, None{}, None{}, None{}, False{}}
212
213def split_eq2(+parts: List<&2, String>) -> List<&2, String>:
214  match parts:
215    case Con{k, Con{v, rest}}:
216      k <> String.join(Con{v, rest}, "=") <> []
217    case _:
218      parts
219
220# `--flag value` and `--flag=value`.
221def split_eq_b(+eq: Bool, +a: String) -> List<&2, String>:
222  match eq:
223    case True{}:
224      split_eq2(String.split(a, '='))
225    case False{}:
226      [a]
227
228def split_eq(+a: String) -> List<&2, String>:
229  split_eq_b(String.starts_with(a, "--") && String.contains(a, "="), a)
230
231def normalize(+xs: List<&2, String>) -> List<&2, String>:
232  match xs:
233    case Nil{}:
234      []
235    case Con{h, t}:
236      List.append(&2, String, split_eq(h), normalize(t))
237
238def bad_value(+flag: String, +kind: String, +v: String) -> String:
239  "argument " ++ flag ++ ": invalid " ++ kind ++ " value: '" ++ v ++ "'"
240
241def with_num2(+is_th: Bool, +is_iv: Bool, +f: Flags, +n: U32) -> Flags:
242  match is_th is_iv f:
243    case True{} _ Flags{o, j, d, th, iv, cd, st, md, ia}:
244      Flags{o, j, d, Some{clamp(n, 5000, 9990)}, iv, cd, st, md, ia}
245    case False{} True{} Flags{o, j, d, th, iv, cd, st, md, ia}:
246      Flags{o, j, d, th, Some{clamp((n / 100 : U32), 15, 3600)}, cd, st, md, ia}
247    case False{} False{} Flags{o, j, d, th, iv, cd, st, md, ia}:
248      Flags{o, j, d, th, iv, Some{clamp((n / 100 : U32), 0, 86400)}, st, md, ia}
249
250def with_num(+f: Flags, +which: String, +n: U32) -> Flags:
251  with_num2(String.eq(which, "--threshold"), String.eq(which, "--interval"), f, n)
252
253def set_num2(+f: Flags, +which: String, +v: String, +c: Maybe<&2, U32>) -> Result<&2, &2, String, Flags>:
254  match c:
255    case None{}:
256      Fail{bad_value(which, "float", v)}
257    case Some{n}:
258      Done{with_num(f, which, n)}
259
260def set_num(+f: Flags, +which: String, +v: String) -> Result<&2, &2, String, Flags>:
261  set_num2(f, which, v, centi(v))
262
263def set_flag(+f: Flags, +name: String) -> Flags:
264  match f:
265    case Flags{o, j, d, th, iv, cd, st, md, ia}:
266      Flags{o || String.eq(name, "--once"), Bool.pick(U32, String.eq(name, "--json"), 1, j), d || String.eq(name, "--dry-run"), th, iv, cd, st, md, ia || String.eq(name, "--include-api-key-accounts")}
267
268def set_str2(+is_strategy: Bool, +ok: Bool, +f: Flags, +v: String) -> Result<&2, &2, String, Flags>:
269  match is_strategy ok f:
270    case True{} True{} Flags{o, j, d, th, iv, cd, st, md, ia}:
271      Done{Flags{o, j, d, th, iv, cd, Some{v}, md, ia}}
272    case True{} False{} Flags{o, j, d, th, iv, cd, st, md, ia}:
273      Fail{"argument --strategy: invalid choice: '" ++ v ++ "' (choose from 'best', 'consume-first')"}
274    case False{} _ Flags{o, j, d, th, iv, cd, st, md, ia}:
275      Done{Flags{o, j, d, th, iv, cd, st, Some{v}, ia}}
276
277def set_str(+f: Flags, +which: String, +v: String) -> Result<&2, &2, String, Flags>:
278  set_str2(String.eq(which, "--strategy"), String.eq(v, "best") || String.eq(v, "consume-first"), f, v)
279
280def takes_value(+a: String) -> Bool:
281  String.eq(a, "--format") || String.eq(a, "--threshold") || String.eq(a, "--interval") || String.eq(a, "--cooldown") || String.eq(a, "--strategy") || String.eq(a, "--model")
282
283def is_bool_flag(+a: String) -> Bool:
284  String.eq(a, "--once") || String.eq(a, "--json") || String.eq(a, "--dry-run") || String.eq(a, "--include-api-key-accounts")
285
286def apply_value2(+str_flag: Bool, +f: Flags, +a: String, +v: String) -> Result<&2, &2, String, Flags>:
287  match str_flag:
288    case True{}:
289      set_str(f, a, v)
290    case False{}:
291      set_num(f, a, v)
292
293# `--format NAME`: the output format of the event stream (src/fmt/code.bend).
294def set_fmt2(m: Maybe<&2, U32>, +f: Flags, +v: String) -> Result<&2, &2, String, Flags>:
295  match m f:
296    case Some{n} Flags{o, j, d, th, iv, cd, st, md, ia}:
297      Done{Flags{o, n, d, th, iv, cd, st, md, ia}}
298    case None{} _:
299      Fail{Code.invalid("--format", v)}
300
301def apply_value3(+is_fmt: Bool, +f: Flags, +a: String, +v: String) -> Result<&2, &2, String, Flags>:
302  match is_fmt:
303    case True{}:
304      set_fmt2(Code.of_name(v), f, v)
305    case False{}:
306      apply_value2(String.eq(a, "--strategy") || String.eq(a, "--model"), f, a, v)
307
308def apply_value(+f: Flags, +a: String, +v: String) -> Result<&2, &2, String, Flags>:
309  apply_value3(String.eq(a, "--format"), f, a, v)
310
311def rest_after2(+valued: Bool, +rest: List<&2, String>) -> List<&2, String>:
312  match valued:
313    case True{}:
314      List.drop(&2, String, rest, 1n)
315    case False{}:
316      rest
317
318def rest_after(+a: String, +rest: List<&2, String>) -> List<&2, String>:
319  rest_after2(takes_value(a), rest)
320
321def valued_flag(+a: String, +rest: List<&2, String>, +f: Flags) -> Result<&2, &2, String, Flags>:
322  match rest:
323    case Nil{}:
324      Fail{"argument " ++ a ++ ": expected one argument"}
325    case Con{v, more}:
326      apply_value(f, a, v)
327
328def advance2(+bool_flag: Bool, +valued: Bool, +a: String, +rest: List<&2, String>, +f: Flags) -> Result<&2, &2, String, Flags>:
329  match bool_flag valued:
330    case True{} _:
331      Done{set_flag(f, a)}
332    case False{} True{}:
333      valued_flag(a, rest, f)
334    case False{} False{}:
335      Fail{"unrecognized arguments: " ++ a}
336
337def advance(+a: String, +rest: List<&2, String>, +f: Flags) -> Result<&2, &2, String, Flags>:
338  advance2(is_bool_flag(a), takes_value(a), a, rest, f)
339
340def step(+fuel: Nat, +xs: List<&2, String>, fr: Result<&2, &2, String, Flags>) -> Result<&2, &2, String, Flags>:
341  match fuel xs fr:
342    case 1n++p Con{a, rest} Done{f}:
343      step(p, rest_after(a, rest), advance(a, rest, f))
344    case _ _ _:
345      fr
346
347# The flags of `cswap auto` (after the verb). Exit 2 territory on Fail.
348def parse_flags(+args: List<&2, String>) -> Result<&2, &2, String, Flags>:
349  step(Nat.add(List.length(&2, String, args), 1n), normalize(args), Done{no_flags()})
350
351# ---- flags over settings ------------------------------------------------------------------------
352
353def over_u(+m: Maybe<&2, U32>, +d: U32) -> U32:
354  match m:
355    case Some{v}:
356      v
357    case None{}:
358      d
359
360def over_s(+m: Maybe<&2, String>, +d: String) -> String:
361  match m:
362    case Some{v}:
363      v
364    case None{}:
365      d
366
367def over_models(+m: Maybe<&2, String>, +d: List<&2, String>) -> List<&2, String>:
368  match m:
369    case Some{v}:
370      parse_models(v)
371    case None{}:
372      d
373
374def merge(+c: Cfg, +f: Flags) -> Cfg:
375  match c f:
376    case Cfg{th, iv, cd, hy, st, ia, ut, ms} Flags{o, j, d, fth, fiv, fcd, fst, fmd, fia}:
377      Cfg{over_u(fth, th), over_u(fiv, iv), over_u(fcd, cd), hy, over_s(fst, st), ia || fia, ut, over_models(fmd, ms)}
378
379def cfg_threshold(+c: Cfg) -> U32:
380  match c:
381    case Cfg{th, iv, cd, hy, st, ia, ut, ms}:
382      th
383
384def cfg_models(+c: Cfg) -> List<&2, String>:
385  match c:
386    case Cfg{th, iv, cd, hy, st, ia, ut, ms}:
387      ms
388
389def cfg_interval(+c: Cfg) -> U32:
390  match c:
391    case Cfg{th, iv, cd, hy, st, ia, ut, ms}:
392      iv
393
394def cfg_strategy(+c: Cfg) -> String:
395  match c:
396    case Cfg{th, iv, cd, hy, st, ia, ut, ms}:
397      st
398
399def cfg_cooldown(+c: Cfg) -> U32:
400  match c:
401    case Cfg{th, iv, cd, hy, st, ia, ut, ms}:
402      cd
403
404def cfg_include_api(+c: Cfg) -> Bool:
405  match c:
406    case Cfg{th, iv, cd, hy, st, ia, ut, ms}:
407      ia