1 import Base 2 import ../http.bend as Http 3 import ../json.bend as Json 4 import ./text.bend as UText 5 6 # ---- error classification -------------------------------------------------- 7 8 # kind: http-, 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). 11 type Err is Data: 12 Err{kind: String, retry: Maybe<&2, U32>, body: String} 13 14 def 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 21 def 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 28 def 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 35 def 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 42 def 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). 51 def retry_after(+head: String) -> Maybe<&2, U32>: 52 retry.of(Http.header("retry-after:", head)) 53 54 def 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 59 def 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. 63 def 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 68 def is_429(e: Err) -> Bool: 69 match e: 70 case Err{kind, retry, body}: 71 String.eq(kind, "http-429") 72 73 def bad(kind: String) -> Err: 74 Err{kind, None{}, ""} 75 76 # ---- the two usage requests ------------------------------------------------ 77 78 def 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 87 def 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 92 def 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. 101 def 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. 111 def 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 118 def 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 125 def 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 132 def 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 137 def 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 144 def 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. 152 def 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 159 def 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 163 def 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 172 def 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 179 def 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). 189 def request_usage_data(token: String, inference_only: Bool) -> IO(Result<&2, &2, Err, Json.Json>): 190 request_data.go(token, inference_only)