1 import Base 2 import ../http.bend as Http 3 import ../json.bend as Json 4 import ../clock.bend as Clock 5 6 def user_agent() -> String: 7 "claude-swap/1.0" 8 9 def usage_url() -> String: 10 "https://api.anthropic.com/api/oauth/usage" 11 12 def messages_url() -> String: 13 "https://api.anthropic.com/v1/messages" 14 15 def token_url() -> String: 16 "https://platform.claude.com/v1/oauth/token" 17 18 def client_id() -> String: 19 "9d1c250a-e61b-44d9-88ed-5944d1962f5e" 20 21 def probe_model() -> String: 22 "claude-haiku-5-5" 23 24 # ---- request text ---------------------------------------------------------- 25 26 def 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 31 def 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 38 def 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 45 def 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 49 def 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 56 def 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 63 def 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. 71 def 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 78 def 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 82 def 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 91 def 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). 97 def float(+s: String) -> Maybe<&2, String>: 98 float.sign(String.starts_with(s, "-"), s) 99 100 def 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). 108 def 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 111 def 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 118 def 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 125 def 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`. 130 def pct1.show(+n: U32) -> String: 131 U32.show((n / 10 : U32)) ++ "." ++ U32.show((n % 10 : U32)) 132 133 def 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 140 def 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 147 def 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 156 def pct1(s: String) -> Maybe<&2, String>: 157 pct1.parts(String.split(s, '.')) 158 159 # ---- rate-limit headers (patch 0003) --------------------------------------- 160 161 def rl() -> String: 162 "anthropic-ratelimit-unified-" 163 164 def 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 171 def 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 178 def 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 185 def 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 192 def 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. 201 def 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 204 def 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 211 def 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 218 def 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. 223 def 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([]))))