usage/req.bend

cswap-bend-cli@0.1.0.0 · 190 lines · 28 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/http.bend as Http
3import cswap-bend-cli@0.1.0.0/json.bend as Json
4import cswap-bend-cli@0.1.0.0/usage/text.bend as UText
5
6# ---- error classification --------------------------------------------------
7
8# kind: http-<code>, timeout, network, bad-response (the reference's
9# _classify_usage_error names); retry: Retry-After seconds when sent; body: the
10# response text, kept only to tell a scope 403 from other 403s (never shown).
11type Err is Data:
12  Err{kind: String, retry: Maybe<&2, U32>, body: String}
13
14def retry.int(ps: List<&2, String>) -> Maybe<&2, U32>:
15  match ps:
16    case Con{i, t}:
17      U32.read(i)
18    case Nil{}:
19      None{}
20
21def retry.norm(m: Maybe<&2, String>) -> Maybe<&2, U32>:
22  match m:
23    case None{}:
24      None{}
25    case Some{n}:
26      retry.int(String.split(n, '.'))
27
28def retry.zero(m: Maybe<&2, String>) -> Maybe<&2, U32>:
29  match m:
30    case None{}:
31      None{}
32    case Some{n}:
33      Some{0}
34
35def retry.neg(neg: Bool, +raw: String) -> Maybe<&2, U32>:
36  match neg:
37    case True{}:
38      retry.zero(UText.float(String.drop(raw, 1n)))
39    case False{}:
40      retry.norm(UText.float(raw))
41
42def retry.of(m: Maybe<&2, String>) -> Maybe<&2, U32>:
43  match m:
44    case None{}:
45      None{}
46    case Some{+raw}:
47      retry.neg(String.starts_with(raw, "-"), raw)
48
49# Retry-After in whole seconds (the seconds form only; fractions drop, a
50# negative value is 0, anything else is none).
51def retry_after(+head: String) -> Maybe<&2, U32>:
52  retry.of(Http.header("retry-after:", head))
53
54def http.err(+r: Http.Reply) -> Err:
55  match r:
56    case Http.Reply{st, head, body}:
57      Err{"http-" ++ U32.show(st), retry_after(head), body}
58
59def ok2xx(+st: U32) -> Bool:
60  U32.is_ge(st, 200) && U32.is_lt(st, 300)
61
62# The reference raises on a 403 whose body says the token lacks scope.
63def scope_error(e: Err) -> Bool:
64  match e:
65    case Err{kind, retry, +body}:
66      String.eq(kind, "http-403") && (String.contains(body, "oauth_scope_insufficient") || String.contains(body, "scope requirement"))
67
68def is_429(e: Err) -> Bool:
69  match e:
70    case Err{kind, retry, body}:
71      String.eq(kind, "http-429")
72
73def bad(kind: String) -> Err:
74  Err{kind, None{}, ""}
75
76# ---- the two usage requests ------------------------------------------------
77
78def probe.pick(data: Maybe<&2, Json.Json>, ok: Bool, e: Err) -> Result<&2, &2, Err, Json.Json>:
79  match data ok:
80    case Some{d} _:
81      Done{d}
82    case None{} True{}:
83      Fail{bad("bad-response")}
84    case None{} False{}:
85      Fail{e}
86
87def probe.reply(+r: Http.Reply) -> Result<&2, &2, Err, Json.Json>:
88  match r:
89    case Http.Reply{st, head, body}:
90      probe.pick(UText.usage_from_ratelimit_headers(head), ok2xx(st), http.err(r))
91
92def probe.result(r: Result<&2, &2, String, Http.Reply>) -> Result<&2, &2, Err, Json.Json>:
93  match r:
94    case Fail{k}:
95      Fail{bad(k)}
96    case Done{rep}:
97      probe.reply(rep)
98
99# The one-token Messages call whose rate-limit headers carry the usage; a
100# non-2xx that still has the headers (429 when exhausted) is a valid reading.
101def probe(token: String) -> IO(Result<&2, &2, Err, Json.Json>):
102  IO.bind(Result<&2, &2, String, Http.Reply>, Result<&2, &2, Err, Json.Json>,
103    Http.request("POST", UText.messages_url(),
104      [("Authorization", "Bearer " ++ token), ("anthropic-version", "2023-06-01"),
105       ("anthropic-beta", "claude-code-20250219,oauth-2025-04-20"), ("content-type", "application/json"),
106       ("User-Agent", UText.user_agent())],
107      UText.probe.body(), 20),
108    r => IO.pure(Result<&2, &2, Err, Json.Json>, probe.result(r)))
109
110# data.get on a non-object raises AttributeError in the reference.
111def body.obj(j: Json.Json) -> Result<&2, &2, Err, Json.Json>:
112  match j:
113    case Json.JObj{f}:
114      Done{j}
115    case _:
116      Fail{bad("AttributeError")}
117
118def body.json(ok: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, Err, Json.Json>:
119  match ok:
120    case Fail{m}:
121      Fail{bad("bad-response")}
122    case Done{j}:
123      body.obj(j)
124
125def usage.pick(ok: Bool, +body: String, e: Err) -> Result<&2, &2, Err, Json.Json>:
126  match ok:
127    case True{}:
128      body.json(Json.parse(body))
129    case False{}:
130      Fail{e}
131
132def usage.reply(+r: Http.Reply) -> Result<&2, &2, Err, Json.Json>:
133  match r:
134    case Http.Reply{st, head, body}:
135      usage.pick(ok2xx(st), body, http.err(r))
136
137def usage.result(r: Result<&2, &2, String, Http.Reply>) -> Result<&2, &2, Err, Json.Json>:
138  match r:
139    case Fail{k}:
140      Fail{bad(k)}
141    case Done{rep}:
142      usage.reply(rep)
143
144def usage.get(token: String) -> IO(Result<&2, &2, Err, Json.Json>):
145  IO.bind(Result<&2, &2, String, Http.Reply>, Result<&2, &2, Err, Json.Json>,
146    Http.request("GET", UText.usage_url(),
147      [("Authorization", "Bearer " ++ token), ("anthropic-beta", "oauth-2025-04-20"), ("User-Agent", UText.user_agent())],
148      "", 5),
149    r => IO.pure(Result<&2, &2, Err, Json.Json>, usage.result(r)))
150
151# A probe that failed after a usage-endpoint 429 hands back the 429.
152def fallback.pick(p: Result<&2, &2, Err, Json.Json>, orig: Err) -> Result<&2, &2, Err, Json.Json>:
153  match p:
154    case Done{d}:
155      Done{d}
156    case Fail{e}:
157      Fail{orig}
158
159def fallback(+token: String, +orig: Err) -> IO(Result<&2, &2, Err, Json.Json>):
160  IO.bind(Result<&2, &2, Err, Json.Json>, Result<&2, &2, Err, Json.Json>, probe(token),
161    p => IO.pure(Result<&2, &2, Err, Json.Json>, fallback.pick(p, orig)))
162
163def route(scope: Bool, rl: Bool, e: Err, token: String) -> IO(Result<&2, &2, Err, Json.Json>):
164  match scope rl:
165    case True{} _:
166      probe(token)
167    case False{} True{}:
168      fallback(token, e)
169    case False{} False{}:
170      IO.pure(Result<&2, &2, Err, Json.Json>, Fail{e})
171
172def usage.next(r: Result<&2, &2, Err, Json.Json>, token: String) -> IO(Result<&2, &2, Err, Json.Json>):
173  match r:
174    case Done{d}:
175      IO.pure(Result<&2, &2, Err, Json.Json>, Done{d})
176    case Fail{+e}:
177      route(scope_error(e), is_429(e), e, token)
178
179def request_data.go(+token: String, inference_only: Bool) -> IO(Result<&2, &2, Err, Json.Json>):
180  match inference_only:
181    case True{}:
182      probe(token)
183    case False{}:
184      IO.bind(Result<&2, &2, Err, Json.Json>, Result<&2, &2, Err, Json.Json>, usage.get(token), r => usage.next(r, token))
185
186# request_usage_data: the usage endpoint, with the probe for an
187# inference-only token, after a scope 403, and after a 429 (whose own error
188# stands if the probe fails too).
189def request_usage_data(token: String, inference_only: Bool) -> IO(Result<&2, &2, Err, Json.Json>):
190  request_data.go(token, inference_only)