usage/text.bend

cswap-bend-cli@0.1.0.0 · 224 lines · 39 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/clock.bend as Clock
5
6def user_agent() -> String:
7  "claude-swap/1.0"
8
9def usage_url() -> String:
10  "https://api.anthropic.com/api/oauth/usage"
11
12def messages_url() -> String:
13  "https://api.anthropic.com/v1/messages"
14
15def token_url() -> String:
16  "https://platform.claude.com/v1/oauth/token"
17
18def client_id() -> String:
19  "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
20
21def probe_model() -> String:
22  "claude-haiku-5-5"
23
24# ---- request text ----------------------------------------------------------
25
26def usage.config(token: String) -> String:
27  Http.config("GET", usage_url(),
28    [("Authorization", "Bearer " ++ token), ("anthropic-beta", "oauth-2025-04-20"), ("User-Agent", user_agent())],
29    "", 5)
30
31def probe.body() -> String:
32  Json.emit_py(Json.of_pairs([
33    ("model", Json.JStr{probe_model()}),
34    ("max_tokens", Json.JNum{"1"}),
35    ("system", Json.arr([Json.of_pairs([("type", Json.JStr{"text"}), ("text", Json.JStr{"You are Claude Code, Anthropic's official CLI for Claude."})])])),
36    ("messages", Json.arr([Json.of_pairs([("role", Json.JStr{"user"}), ("content", Json.JStr{"."})])]))]))
37
38def probe.config(token: String) -> String:
39  Http.config("POST", messages_url(),
40    [("Authorization", "Bearer " ++ token), ("anthropic-version", "2023-06-01"),
41     ("anthropic-beta", "claude-code-20250219,oauth-2025-04-20"), ("content-type", "application/json"),
42     ("User-Agent", user_agent())],
43    probe.body(), 20)
44
45def refresh.body(refresh_token: String) -> String:
46  Json.emit_py(Json.of_pairs([
47    ("grant_type", Json.JStr{"refresh_token"}), ("refresh_token", Json.JStr{refresh_token}), ("client_id", Json.JStr{client_id()})]))
48
49def refresh.config(refresh_token: String) -> String:
50  Http.config("POST", token_url(),
51    [("Content-Type", "application/json"), ("User-Agent", user_agent())],
52    refresh.body(refresh_token), 10)
53
54# ---- numbers as text -------------------------------------------------------
55
56def maybe.none_if(bad: Bool, s: String) -> Maybe<&2, String>:
57  match bad:
58    case True{}:
59      None{}
60    case False{}:
61      Some{s}
62
63def strip0.pick(zero: Bool, more: Bool, c: Char, t: String, rest: String) -> String:
64  match zero more:
65    case True{} True{}:
66      rest
67    case _ _:
68      SCon{c, t}
69
70# Leading zeros dropped, keeping at least one digit.
71def strip0(s: String) -> String:
72  match s:
73    case SNil{}:
74      "0"
75    case SCon{+c, +t}:
76      strip0.pick(Char.is_eq(c, '0'), Bool.not(String.is_empty(t)), c, t, strip0(t))
77
78def float.mk(neg: Bool, +i: String, +f: String) -> Maybe<&2, String>:
79  maybe.none_if(Bool.not(Clock.all_digits(i) && Clock.all_digits(f) && Bool.not(String.is_empty(i))),
80    Clock.sel.s(neg, "-", "") ++ strip0(i) ++ "." ++ String.reverse(strip0(String.reverse(f))))
81
82def float.parts(neg: Bool, ps: List<&2, String>) -> Maybe<&2, String>:
83  match ps:
84    case Con{i, Nil{}}:
85      float.mk(neg, i, "0")
86    case Con{i, Con{f, Nil{}}}:
87      float.mk(neg, i, f)
88    case _:
89      None{}
90
91def float.sign(+neg: Bool, +s: String) -> Maybe<&2, String>:
92  float.parts(neg, String.split(Clock.sel.s(neg, String.drop(s, 1n), s), '.'))
93
94# A JSON number's text as Python prints float(text): `50` -> `50.0`, `5.50` ->
95# `5.5`. None for anything but plain decimals (exponents, signs after the
96# first, text).
97def float(+s: String) -> Maybe<&2, String>:
98  float.sign(String.starts_with(s, "-"), s)
99
100def split.int(ps: List<&2, String>) -> Maybe<&2, String>:
101  match ps:
102    case Con{i, t}:
103      Some{i}
104    case Nil{}:
105      None{}
106
107# float(text) / 100 as Python prints it (decimal shift, exact for decimals).
108def div100.fin(+i2: String, +f: String) -> Maybe<&2, String>:
109  float(String.take(i2, Nat.sub(String.length(i2), 2n)) ++ "." ++ String.drop(i2, Nat.sub(String.length(i2), 2n)) ++ f)
110
111def div100.parts(ps: List<&2, String>) -> Maybe<&2, String>:
112  match ps:
113    case Con{i, Con{f, Nil{}}}:
114      div100.fin("00" ++ i, f)
115    case _:
116      None{}
117
118def div100.go(m: Maybe<&2, String>) -> Maybe<&2, String>:
119  match m:
120    case None{}:
121      None{}
122    case Some{n}:
123      div100.parts(String.split(n, '.'))
124
125def div100(+s: String) -> Maybe<&2, String>:
126  div100.go(float(s))
127
128# round(float(s) * 100, 1) for a decimal fraction: tenths of a percent, as
129# text. `0.25` -> `25.0`, `0.4256` -> `42.6`.
130def pct1.show(+n: U32) -> String:
131  U32.show((n / 10 : U32)) ++ "." ++ U32.show((n % 10 : U32))
132
133def pct1.fin(+i: String, +f: String) -> Maybe<&2, String>:
134  do Maybe<&2, String>:
135    ip : U32 <- U32.read(i)
136    fp : U32 <- Clock.num(f ++ "0000", 0n, 3n)
137    rd : U32 <- Clock.num(f ++ "0000", 3n, 1n)
138    return pct1.show((ip * 1000 + fp + Clock.sel(U32.is_ge(rd, 5), 1, 0) : U32))
139
140def pct1.pick(ok: Bool, +i: String, +f: String) -> Maybe<&2, String>:
141  match ok:
142    case True{}:
143      pct1.fin(i, f)
144    case False{}:
145      None{}
146
147def pct1.parts(ps: List<&2, String>) -> Maybe<&2, String>:
148  match ps:
149    case Con{+i, Nil{}}:
150      pct1.pick(Clock.all_digits(i) && Bool.not(String.is_empty(i)) && Nat.is_le(String.length(i), 6n), i, "")
151    case Con{+i, Con{+f, Nil{}}}:
152      pct1.pick(Clock.all_digits(i) && Clock.all_digits(f) && Bool.not(String.is_empty(i)) && Nat.is_le(String.length(i), 6n), i, f)
153    case _:
154      None{}
155
156def pct1(s: String) -> Maybe<&2, String>:
157  pct1.parts(String.split(s, '.'))
158
159# ---- rate-limit headers (patch 0003) ---------------------------------------
160
161def rl() -> String:
162  "anthropic-ratelimit-unified-"
163
164def reset.epoch(m: Maybe<&2, U32>) -> Maybe<&2, String>:
165  match m:
166    case None{}:
167      None{}
168    case Some{t}:
169      Some{Clock.iso8601_offset(t)}
170
171def reset.iso(m: Maybe<&2, String>) -> Maybe<&2, String>:
172  match m:
173    case None{}:
174      None{}
175    case Some{s}:
176      reset.epoch(U32.read(s))
177
178def hwin.mk(pct: String, reset: Maybe<&2, String>) -> Maybe<&2, Json.Json>:
179  match reset:
180    case None{}:
181      Some{Json.of_pairs([("utilization", Json.JNum{pct})])}
182    case Some{r}:
183      Some{Json.of_pairs([("utilization", Json.JNum{pct}), ("resets_at", Json.JStr{r})])}
184
185def hwin.pick(pct: Maybe<&2, String>, reset: Maybe<&2, String>) -> Maybe<&2, Json.Json>:
186  match pct:
187    case None{}:
188      None{}
189    case Some{p}:
190      hwin.mk(p, reset)
191
192def hwin.util(u: Maybe<&2, String>, reset: Maybe<&2, String>) -> Maybe<&2, Json.Json>:
193  match u:
194    case None{}:
195      None{}
196    case Some{x}:
197      hwin.pick(pct1(x), reset.iso(reset))
198
199# One window ("5h" or "7d") of the rate-limit headers: utilization is a
200# fraction, reset an epoch. No utilization header, no window.
201def hwin(+p: String, +head: String) -> Maybe<&2, Json.Json>:
202  hwin.util(Http.header(rl() ++ p ++ "-utilization:", head), Http.header(rl() ++ p ++ "-reset:", head))
203
204def hdata.set(k: String, w: Maybe<&2, Json.Json>, j: Json.Json) -> Json.Json:
205  match w:
206    case None{}:
207      j
208    case Some{v}:
209      Json.set(k, v, j)
210
211def hdata.keys(ks: List<&2, String>, j: Json.Json) -> Maybe<&2, Json.Json>:
212  match ks:
213    case Nil{}:
214      None{}
215    case Con{h, t}:
216      Some{j}
217
218def hdata.fin(+j: Json.Json) -> Maybe<&2, Json.Json>:
219  hdata.keys(Json.keys(j), j)
220
221# `anthropic-ratelimit-unified-*` response headers (the raw header block) in
222# the /api/oauth/usage shape; None when neither window is present.
223def usage_from_ratelimit_headers(+head: String) -> Maybe<&2, Json.Json>:
224  hdata.fin(hdata.set("seven_day", hwin("7d", head), hdata.set("five_hour", hwin("5h", head), Json.of_pairs([]))))