tokenbiryani 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tokenbiryani/__init__.py +3 -0
- tokenbiryani/api/__init__.py +0 -0
- tokenbiryani/api/app.py +583 -0
- tokenbiryani/api/asgi.py +32 -0
- tokenbiryani/cli.py +1045 -0
- tokenbiryani/config.py +532 -0
- tokenbiryani/core/__init__.py +0 -0
- tokenbiryani/core/account.py +258 -0
- tokenbiryani/core/batch.py +135 -0
- tokenbiryani/core/breaker.py +53 -0
- tokenbiryani/core/cacheadvice.py +239 -0
- tokenbiryani/core/diagnostics.py +131 -0
- tokenbiryani/core/estimator.py +180 -0
- tokenbiryani/core/gateway.py +2395 -0
- tokenbiryani/core/handoff.py +87 -0
- tokenbiryani/core/keys.py +199 -0
- tokenbiryani/core/limits.py +440 -0
- tokenbiryani/core/oauth.py +222 -0
- tokenbiryani/core/pacing.py +320 -0
- tokenbiryani/core/queue.py +132 -0
- tokenbiryani/core/router.py +323 -0
- tokenbiryani/core/secrets.py +114 -0
- tokenbiryani/core/session.py +117 -0
- tokenbiryani/dashboard/__init__.py +56 -0
- tokenbiryani/dashboard/console.css +610 -0
- tokenbiryani/dashboard/console.html +3250 -0
- tokenbiryani/observability/__init__.py +0 -0
- tokenbiryani/observability/events.py +171 -0
- tokenbiryani/observability/usage.py +226 -0
- tokenbiryani/prices.yaml +77 -0
- tokenbiryani/providers/__init__.py +0 -0
- tokenbiryani/providers/anthropic_api.py +118 -0
- tokenbiryani/providers/base.py +173 -0
- tokenbiryani/providers/bedrock.py +182 -0
- tokenbiryani/providers/oauth.py +165 -0
- tokenbiryani/providers/oauth_credentials.py +293 -0
- tokenbiryani/providers/translate.py +35 -0
- tokenbiryani/providers/vertex.py +144 -0
- tokenbiryani/proxy/__init__.py +0 -0
- tokenbiryani/proxy/errors.py +169 -0
- tokenbiryani/proxy/sse.py +98 -0
- tokenbiryani/store/__init__.py +0 -0
- tokenbiryani/store/base.py +150 -0
- tokenbiryani/store/memory.py +149 -0
- tokenbiryani/store/redis_store.py +222 -0
- tokenbiryani/store/sqlite.py +336 -0
- tokenbiryani-0.2.0.dist-info/METADATA +697 -0
- tokenbiryani-0.2.0.dist-info/RECORD +51 -0
- tokenbiryani-0.2.0.dist-info/WHEEL +4 -0
- tokenbiryani-0.2.0.dist-info/entry_points.txt +2 -0
- tokenbiryani-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""The rate-limit mirror, token estimation, and leases.
|
|
2
|
+
|
|
3
|
+
Every Anthropic response carries the account's remaining budget. Mirroring those
|
|
4
|
+
headers gives real-time availability with no probe traffic — this is the routing
|
|
5
|
+
signal the whole gateway is built on.
|
|
6
|
+
|
|
7
|
+
Leases are the correctness half. Without an atomic reservation, N concurrent requests
|
|
8
|
+
all read the same "plenty of headroom" and stampede one account into a 429.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as _dt
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional
|
|
18
|
+
|
|
19
|
+
#: Rough bytes-per-token for JSON-serialised request bodies. Deliberately low
|
|
20
|
+
#: (pessimistic: it over-counts tokens) so leases err toward reserving too much.
|
|
21
|
+
CHARS_PER_TOKEN = 3.5
|
|
22
|
+
|
|
23
|
+
DEFAULT_MAX_TOKENS = 1024
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_reset(value: Optional[str]) -> Optional[float]:
|
|
27
|
+
"""Anthropic sends RFC3339. Python 3.8's fromisoformat can't take a trailing Z."""
|
|
28
|
+
if not value:
|
|
29
|
+
return None
|
|
30
|
+
text = value.strip()
|
|
31
|
+
try:
|
|
32
|
+
return float(text)
|
|
33
|
+
except ValueError:
|
|
34
|
+
pass
|
|
35
|
+
if text.endswith("Z"):
|
|
36
|
+
text = text[:-1] + "+00:00"
|
|
37
|
+
try:
|
|
38
|
+
parsed = _dt.datetime.fromisoformat(text)
|
|
39
|
+
except ValueError:
|
|
40
|
+
return None
|
|
41
|
+
if parsed.tzinfo is None:
|
|
42
|
+
parsed = parsed.replace(tzinfo=_dt.timezone.utc)
|
|
43
|
+
return parsed.timestamp()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class TokenEstimate:
|
|
48
|
+
input_tokens: int
|
|
49
|
+
output_tokens: int
|
|
50
|
+
#: The caller's own `max_tokens`. `output_tokens` is what gets leased and may be
|
|
51
|
+
#: a prediction well below it; this stays the true upper bound, and it is what
|
|
52
|
+
#: `exceeds_capacity` asks about. "Could this account ever serve the request"
|
|
53
|
+
#: has to be answered against what the caller is allowed to receive, not against
|
|
54
|
+
#: what we expect it to receive.
|
|
55
|
+
output_ceiling: int = 0
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
if not self.output_ceiling:
|
|
59
|
+
self.output_ceiling = self.output_tokens
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def total(self) -> int:
|
|
63
|
+
return self.input_tokens + self.output_tokens
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def estimate_request(
|
|
67
|
+
body: Mapping[str, Any],
|
|
68
|
+
safety_margin: float = 1.15,
|
|
69
|
+
predictor: Optional[Callable[[str, int], int]] = None,
|
|
70
|
+
) -> TokenEstimate:
|
|
71
|
+
"""Estimate a request's cost without pulling in a tokenizer.
|
|
72
|
+
|
|
73
|
+
Input is approximated from the serialised body. Output defaults to the caller's
|
|
74
|
+
own ``max_tokens`` — the only honest upper bound available before the fact — and
|
|
75
|
+
a ``predictor`` may lower it to something the model has actually been returning.
|
|
76
|
+
It may only ever lower it: the ceiling is kept on the estimate either way.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
serialised = json.dumps(
|
|
80
|
+
{k: v for k, v in body.items() if k not in ("max_tokens", "stream")},
|
|
81
|
+
ensure_ascii=False,
|
|
82
|
+
)
|
|
83
|
+
except (TypeError, ValueError):
|
|
84
|
+
serialised = str(body)
|
|
85
|
+
approx_input = int(math.ceil(len(serialised) / CHARS_PER_TOKEN * safety_margin))
|
|
86
|
+
raw_max = body.get("max_tokens")
|
|
87
|
+
try:
|
|
88
|
+
max_tokens = int(raw_max) if raw_max is not None else DEFAULT_MAX_TOKENS
|
|
89
|
+
except (TypeError, ValueError):
|
|
90
|
+
max_tokens = DEFAULT_MAX_TOKENS
|
|
91
|
+
ceiling = max(1, max_tokens)
|
|
92
|
+
leased = ceiling
|
|
93
|
+
if predictor is not None:
|
|
94
|
+
try:
|
|
95
|
+
leased = max(1, min(ceiling, int(predictor(str(body.get("model") or ""), ceiling))))
|
|
96
|
+
except Exception: # noqa: BLE001 - a bad predictor must not fail a request
|
|
97
|
+
leased = ceiling
|
|
98
|
+
return TokenEstimate(
|
|
99
|
+
input_tokens=max(1, approx_input),
|
|
100
|
+
output_tokens=leased,
|
|
101
|
+
output_ceiling=ceiling,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class LimitWindow:
|
|
107
|
+
"""One rate-limit dimension: requests, input tokens, or output tokens."""
|
|
108
|
+
|
|
109
|
+
limit: Optional[int] = None
|
|
110
|
+
remaining: Optional[int] = None
|
|
111
|
+
reset_at: Optional[float] = None
|
|
112
|
+
updated_at: Optional[float] = None
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def known(self) -> bool:
|
|
116
|
+
return self.remaining is not None
|
|
117
|
+
|
|
118
|
+
def refilled(self, now: float) -> bool:
|
|
119
|
+
return self.reset_at is not None and now >= self.reset_at
|
|
120
|
+
|
|
121
|
+
def available(self, reserved: int, now: float) -> Optional[int]:
|
|
122
|
+
"""Projected budget: what the mirror says, minus what is already leased."""
|
|
123
|
+
if self.remaining is None:
|
|
124
|
+
return None
|
|
125
|
+
base = self.remaining
|
|
126
|
+
if self.refilled(now) and self.limit is not None:
|
|
127
|
+
base = self.limit
|
|
128
|
+
return max(0, base - reserved)
|
|
129
|
+
|
|
130
|
+
def fraction(self, reserved: int, now: float) -> float:
|
|
131
|
+
"""0..1 headroom. Unknown windows read as full — optimistic on first contact."""
|
|
132
|
+
available = self.available(reserved, now)
|
|
133
|
+
if available is None or not self.limit:
|
|
134
|
+
return 1.0
|
|
135
|
+
return max(0.0, min(1.0, available / float(self.limit)))
|
|
136
|
+
|
|
137
|
+
def seconds_to_reset(self, now: float) -> Optional[float]:
|
|
138
|
+
if self.reset_at is None:
|
|
139
|
+
return None
|
|
140
|
+
return max(0.0, self.reset_at - now)
|
|
141
|
+
|
|
142
|
+
def update(
|
|
143
|
+
self,
|
|
144
|
+
limit: Optional[str],
|
|
145
|
+
remaining: Optional[str],
|
|
146
|
+
reset: Optional[str],
|
|
147
|
+
now: float,
|
|
148
|
+
) -> None:
|
|
149
|
+
if limit is not None:
|
|
150
|
+
try:
|
|
151
|
+
self.limit = int(limit)
|
|
152
|
+
except (TypeError, ValueError):
|
|
153
|
+
pass
|
|
154
|
+
if remaining is not None:
|
|
155
|
+
try:
|
|
156
|
+
self.remaining = int(remaining)
|
|
157
|
+
except (TypeError, ValueError):
|
|
158
|
+
pass
|
|
159
|
+
parsed_reset = parse_reset(reset)
|
|
160
|
+
if parsed_reset is not None:
|
|
161
|
+
self.reset_at = parsed_reset
|
|
162
|
+
self.updated_at = now
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
#: The rolling windows a Claude subscription session reports. An API-key account
|
|
166
|
+
#: sends {limit, remaining, reset} triples; a subscription sends how much of a
|
|
167
|
+
#: rolling window it has spent, and nothing else.
|
|
168
|
+
UNIFIED_WINDOWS = ("5h", "7d")
|
|
169
|
+
|
|
170
|
+
#: The only unified status that means "this request would be refused right now".
|
|
171
|
+
#: Anything else — `allowed`, `allowed_warning` — is a request we should still make.
|
|
172
|
+
UNIFIED_REJECTED = "rejected"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class UnifiedWindow:
|
|
177
|
+
"""One `anthropic-ratelimit-unified-*` window: how much of it is spent.
|
|
178
|
+
|
|
179
|
+
A subscription session says `utilization: 0.34`, not "412 requests left", so
|
|
180
|
+
there is no budget to lease against — but `1 - utilization` is a measured
|
|
181
|
+
headroom number, and routing on that is not the same as routing on a guess.
|
|
182
|
+
Leases and the capacity horizon still cannot use it: both need absolute token
|
|
183
|
+
counts, and a fraction cannot be decremented by 4,000 tokens.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
status: Optional[str] = None
|
|
187
|
+
utilization: Optional[float] = None
|
|
188
|
+
reset_at: Optional[float] = None
|
|
189
|
+
updated_at: Optional[float] = None
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def known(self) -> bool:
|
|
193
|
+
return self.utilization is not None
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def rejected(self) -> bool:
|
|
197
|
+
return (self.status or "").lower() == UNIFIED_REJECTED
|
|
198
|
+
|
|
199
|
+
def headroom(self) -> Optional[float]:
|
|
200
|
+
if self.utilization is None:
|
|
201
|
+
return None
|
|
202
|
+
return max(0.0, min(1.0, 1.0 - self.utilization))
|
|
203
|
+
|
|
204
|
+
def seconds_to_reset(self, now: float) -> Optional[float]:
|
|
205
|
+
if self.reset_at is None:
|
|
206
|
+
return None
|
|
207
|
+
return max(0.0, self.reset_at - now)
|
|
208
|
+
|
|
209
|
+
def update(
|
|
210
|
+
self,
|
|
211
|
+
status: Optional[str],
|
|
212
|
+
utilization: Optional[str],
|
|
213
|
+
reset: Optional[str],
|
|
214
|
+
now: float,
|
|
215
|
+
) -> None:
|
|
216
|
+
if status is not None:
|
|
217
|
+
self.status = str(status)
|
|
218
|
+
if utilization is not None:
|
|
219
|
+
try:
|
|
220
|
+
self.utilization = float(utilization)
|
|
221
|
+
except (TypeError, ValueError):
|
|
222
|
+
pass
|
|
223
|
+
parsed_reset = parse_reset(reset)
|
|
224
|
+
if parsed_reset is not None:
|
|
225
|
+
self.reset_at = parsed_reset
|
|
226
|
+
if status is not None or utilization is not None:
|
|
227
|
+
self.updated_at = now
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass
|
|
231
|
+
class Lease:
|
|
232
|
+
"""An atomic reservation held against an account for the life of one request."""
|
|
233
|
+
|
|
234
|
+
account_id: str
|
|
235
|
+
input_tokens: int
|
|
236
|
+
output_tokens: int
|
|
237
|
+
requests: int = 1
|
|
238
|
+
released: bool = False
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class LimitMirror:
|
|
243
|
+
"""Per-account mirror of the upstream's advertised budget, plus outstanding leases.
|
|
244
|
+
|
|
245
|
+
``observable`` is False for upstreams that report no rate-limit headers. Their
|
|
246
|
+
windows never populate, and an unknown window otherwise reads as full — which
|
|
247
|
+
would make such an account beat every account that honestly reports a partly-used
|
|
248
|
+
budget. Instead they score at ``assumed_headroom`` and are left out of anything
|
|
249
|
+
that claims to know future capacity.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
requests: LimitWindow = field(default_factory=LimitWindow)
|
|
253
|
+
input_tokens: LimitWindow = field(default_factory=LimitWindow)
|
|
254
|
+
output_tokens: LimitWindow = field(default_factory=LimitWindow)
|
|
255
|
+
|
|
256
|
+
#: Subscription sessions report these instead of the three windows above. They
|
|
257
|
+
#: are read whatever `observable` says: an account that sends both should be
|
|
258
|
+
#: held to whichever is tighter.
|
|
259
|
+
unified: Dict[str, UnifiedWindow] = field(
|
|
260
|
+
default_factory=lambda: {name: UnifiedWindow() for name in UNIFIED_WINDOWS}
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
observable: bool = True
|
|
264
|
+
assumed_headroom: float = 0.5
|
|
265
|
+
|
|
266
|
+
reserved_requests: int = 0
|
|
267
|
+
reserved_input: int = 0
|
|
268
|
+
reserved_output: int = 0
|
|
269
|
+
|
|
270
|
+
def update_from_headers(self, headers: Mapping[str, str], now: float) -> None:
|
|
271
|
+
lowered = {k.lower(): v for k, v in headers.items()}
|
|
272
|
+
|
|
273
|
+
def triple(prefix: str):
|
|
274
|
+
return (
|
|
275
|
+
lowered.get(f"anthropic-ratelimit-{prefix}-limit"),
|
|
276
|
+
lowered.get(f"anthropic-ratelimit-{prefix}-remaining"),
|
|
277
|
+
lowered.get(f"anthropic-ratelimit-{prefix}-reset"),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
for window, prefix in (
|
|
281
|
+
(self.requests, "requests"),
|
|
282
|
+
(self.input_tokens, "input-tokens"),
|
|
283
|
+
(self.output_tokens, "output-tokens"),
|
|
284
|
+
):
|
|
285
|
+
limit, remaining, reset = triple(prefix)
|
|
286
|
+
window.update(limit, remaining, reset, now)
|
|
287
|
+
|
|
288
|
+
for name, rolling in self.unified.items():
|
|
289
|
+
rolling.update(
|
|
290
|
+
lowered.get(f"anthropic-ratelimit-unified-{name}-status"),
|
|
291
|
+
lowered.get(f"anthropic-ratelimit-unified-{name}-utilization"),
|
|
292
|
+
lowered.get(f"anthropic-ratelimit-unified-{name}-reset"),
|
|
293
|
+
now,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def reserve(self, estimate: TokenEstimate, account_id: str) -> Lease:
|
|
297
|
+
lease = Lease(
|
|
298
|
+
account_id=account_id,
|
|
299
|
+
input_tokens=estimate.input_tokens,
|
|
300
|
+
output_tokens=estimate.output_tokens,
|
|
301
|
+
)
|
|
302
|
+
self.reserved_requests += lease.requests
|
|
303
|
+
self.reserved_input += lease.input_tokens
|
|
304
|
+
self.reserved_output += lease.output_tokens
|
|
305
|
+
return lease
|
|
306
|
+
|
|
307
|
+
def release(
|
|
308
|
+
self,
|
|
309
|
+
lease: Lease,
|
|
310
|
+
actual_input: Optional[int] = None,
|
|
311
|
+
actual_output: Optional[int] = None,
|
|
312
|
+
now: Optional[float] = None,
|
|
313
|
+
) -> None:
|
|
314
|
+
"""Give the reservation back, then reconcile the mirror against real usage.
|
|
315
|
+
|
|
316
|
+
Reconciliation only matters when the response carried no rate-limit headers
|
|
317
|
+
(an error, a transport failure); when it did, ``update_from_headers`` has
|
|
318
|
+
already replaced these numbers with the upstream's own accounting.
|
|
319
|
+
"""
|
|
320
|
+
if lease.released:
|
|
321
|
+
return
|
|
322
|
+
lease.released = True
|
|
323
|
+
self.reserved_requests = max(0, self.reserved_requests - lease.requests)
|
|
324
|
+
self.reserved_input = max(0, self.reserved_input - lease.input_tokens)
|
|
325
|
+
self.reserved_output = max(0, self.reserved_output - lease.output_tokens)
|
|
326
|
+
|
|
327
|
+
if now is None:
|
|
328
|
+
return
|
|
329
|
+
for window, actual in (
|
|
330
|
+
(self.input_tokens, actual_input),
|
|
331
|
+
(self.output_tokens, actual_output),
|
|
332
|
+
):
|
|
333
|
+
current = window.remaining
|
|
334
|
+
if actual is None or current is None or window.updated_at is None:
|
|
335
|
+
continue
|
|
336
|
+
if window.updated_at < now:
|
|
337
|
+
window.remaining = max(0, current - int(actual))
|
|
338
|
+
|
|
339
|
+
def can_serve(self, estimate: TokenEstimate, now: float) -> bool:
|
|
340
|
+
# A unified window that says `rejected` is the upstream telling us the next
|
|
341
|
+
# request is a 429. Believing it is cheaper than proving it.
|
|
342
|
+
if self.unified_rejected():
|
|
343
|
+
return False
|
|
344
|
+
checks = (
|
|
345
|
+
(self.requests, self.reserved_requests, 1),
|
|
346
|
+
(self.input_tokens, self.reserved_input, estimate.input_tokens),
|
|
347
|
+
(self.output_tokens, self.reserved_output, estimate.output_tokens),
|
|
348
|
+
)
|
|
349
|
+
for window, reserved, need in checks:
|
|
350
|
+
available = window.available(reserved, now)
|
|
351
|
+
if available is not None and available < need:
|
|
352
|
+
return False
|
|
353
|
+
return True
|
|
354
|
+
|
|
355
|
+
def exceeds_capacity(self, estimate: TokenEstimate) -> bool:
|
|
356
|
+
"""True when no reset could ever make this account able to serve the request."""
|
|
357
|
+
for window, need in (
|
|
358
|
+
(self.input_tokens, estimate.input_tokens),
|
|
359
|
+
(self.output_tokens, estimate.output_ceiling),
|
|
360
|
+
):
|
|
361
|
+
if window.limit is not None and need > window.limit:
|
|
362
|
+
return True
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def unified_known(self) -> bool:
|
|
367
|
+
return any(window.known for window in self.unified.values())
|
|
368
|
+
|
|
369
|
+
def unified_headroom(self) -> Optional[float]:
|
|
370
|
+
"""The tightest unified window, or None when none were reported."""
|
|
371
|
+
values: List[float] = []
|
|
372
|
+
for window in self.unified.values():
|
|
373
|
+
headroom = window.headroom()
|
|
374
|
+
if headroom is not None:
|
|
375
|
+
values.append(headroom)
|
|
376
|
+
return min(values) if values else None
|
|
377
|
+
|
|
378
|
+
def unified_rejected(self) -> bool:
|
|
379
|
+
return any(window.rejected for window in self.unified.values())
|
|
380
|
+
|
|
381
|
+
def headroom(self, now: float) -> float:
|
|
382
|
+
"""The binding constraint across every dimension the upstream reported.
|
|
383
|
+
|
|
384
|
+
Unobservable used to mean "guess `assumed_headroom` and hope". It only has
|
|
385
|
+
to mean that when the upstream reported nothing at all: a subscription
|
|
386
|
+
session reports unified utilisation, which is a measurement, and a guess
|
|
387
|
+
must never win a comparison against one.
|
|
388
|
+
"""
|
|
389
|
+
unified = self.unified_headroom()
|
|
390
|
+
if not self.observable and not self.input_tokens.known:
|
|
391
|
+
return self.assumed_headroom if unified is None else unified
|
|
392
|
+
classic = min(
|
|
393
|
+
self.requests.fraction(self.reserved_requests, now),
|
|
394
|
+
self.input_tokens.fraction(self.reserved_input, now),
|
|
395
|
+
self.output_tokens.fraction(self.reserved_output, now),
|
|
396
|
+
)
|
|
397
|
+
return classic if unified is None else min(classic, unified)
|
|
398
|
+
|
|
399
|
+
def next_reset(self, now: float) -> Optional[float]:
|
|
400
|
+
candidates: List[float] = []
|
|
401
|
+
classic: List[Any] = [self.requests, self.input_tokens, self.output_tokens]
|
|
402
|
+
for window in classic + list(self.unified.values()):
|
|
403
|
+
seconds = window.seconds_to_reset(now)
|
|
404
|
+
if seconds is not None:
|
|
405
|
+
candidates.append(seconds)
|
|
406
|
+
return min(candidates) if candidates else None
|
|
407
|
+
|
|
408
|
+
def snapshot(self, now: float) -> Dict[str, Any]:
|
|
409
|
+
def window(w: LimitWindow, reserved: int) -> Dict[str, Any]:
|
|
410
|
+
reset_in = w.seconds_to_reset(now)
|
|
411
|
+
return {
|
|
412
|
+
"limit": w.limit,
|
|
413
|
+
"remaining": w.remaining,
|
|
414
|
+
"reserved": reserved,
|
|
415
|
+
"available": w.available(reserved, now),
|
|
416
|
+
"fraction": round(w.fraction(reserved, now), 4),
|
|
417
|
+
"reset_in": None if reset_in is None else round(reset_in, 1),
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
def unified(w: UnifiedWindow) -> Dict[str, Any]:
|
|
421
|
+
reset_in = w.seconds_to_reset(now)
|
|
422
|
+
headroom = w.headroom()
|
|
423
|
+
return {
|
|
424
|
+
"status": w.status,
|
|
425
|
+
"utilization": None if w.utilization is None else round(w.utilization, 4),
|
|
426
|
+
"headroom": None if headroom is None else round(headroom, 4),
|
|
427
|
+
"reset_in": None if reset_in is None else round(reset_in, 1),
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
"observable": self.observable,
|
|
432
|
+
"requests": window(self.requests, self.reserved_requests),
|
|
433
|
+
"input_tokens": window(self.input_tokens, self.reserved_input),
|
|
434
|
+
"output_tokens": window(self.output_tokens, self.reserved_output),
|
|
435
|
+
"headroom": round(self.headroom(now), 4),
|
|
436
|
+
# Present but empty for an API-key account, so the console can render
|
|
437
|
+
# one shape and decide what to draw from `unified_known`.
|
|
438
|
+
"unified": {name: unified(w) for name, w in self.unified.items()},
|
|
439
|
+
"unified_known": self.unified_known,
|
|
440
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""The OAuth 2.0 + PKCE login flow behind "Log in with Claude".
|
|
2
|
+
|
|
3
|
+
Why the gateway runs the login itself rather than reading a token someone else
|
|
4
|
+
wrote: the credentials file the Claude CLI maintains is exactly one subscription per
|
|
5
|
+
machine. Pooling — the entire point of this gateway — needs several, each with its
|
|
6
|
+
own session. See ADR-0004.
|
|
7
|
+
|
|
8
|
+
Two things are worth knowing before reading further.
|
|
9
|
+
|
|
10
|
+
**The endpoints are configuration, not constants.** Anthropic does not document the
|
|
11
|
+
OAuth endpoints its first-party clients use, and this project has never been run
|
|
12
|
+
against a real subscription session. Rather than hard-code a guess that would fail
|
|
13
|
+
mysteriously, `oauth.client_id`, `oauth.authorize_url` and `oauth.token_url` are
|
|
14
|
+
required config with an error message that says so. The flow below is plain RFC 7636
|
|
15
|
+
and is correct whatever those values turn out to be.
|
|
16
|
+
|
|
17
|
+
**Refresh is implemented here.** Reading a file the Claude CLI refreshes works for a
|
|
18
|
+
single session and nothing else; a gateway holding several has no CLI to lean on, so
|
|
19
|
+
it renews them itself, ahead of expiry.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import hashlib
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any, Dict, Optional
|
|
30
|
+
from urllib.parse import urlencode
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OAuthError(RuntimeError):
|
|
36
|
+
"""A login could not be completed. The message is shown to the operator."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _b64url(raw: bytes) -> str:
|
|
40
|
+
"""base64url with the padding stripped, as PKCE requires."""
|
|
41
|
+
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def make_verifier() -> str:
|
|
45
|
+
"""A PKCE code verifier: 43-128 chars of unreserved characters (RFC 7636 §4.1)."""
|
|
46
|
+
return _b64url(os.urandom(64))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def challenge_for(verifier: str) -> str:
|
|
50
|
+
return _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class PendingLogin:
|
|
55
|
+
"""One in-flight authorization, held between `start` and `complete`."""
|
|
56
|
+
|
|
57
|
+
state: str
|
|
58
|
+
verifier: str
|
|
59
|
+
account_id: str
|
|
60
|
+
name: str
|
|
61
|
+
started_at: float = field(default_factory=time.time)
|
|
62
|
+
|
|
63
|
+
def expired(self, now: float, ttl: float = 900.0) -> bool:
|
|
64
|
+
return now - self.started_at > ttl
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class OAuthTokens:
|
|
69
|
+
access_token: str
|
|
70
|
+
refresh_token: str = ""
|
|
71
|
+
expires_at: Optional[float] = None
|
|
72
|
+
scope: str = ""
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_response(cls, payload: Dict[str, Any], now: float) -> OAuthTokens:
|
|
76
|
+
access = str(payload.get("access_token") or "")
|
|
77
|
+
if not access:
|
|
78
|
+
raise OAuthError(
|
|
79
|
+
"the token endpoint returned no access_token. Response keys: "
|
|
80
|
+
+ ", ".join(sorted(payload)) or "(empty body)"
|
|
81
|
+
)
|
|
82
|
+
expires_in = payload.get("expires_in")
|
|
83
|
+
expires_at = None
|
|
84
|
+
if expires_in is not None:
|
|
85
|
+
try:
|
|
86
|
+
expires_at = now + float(expires_in)
|
|
87
|
+
except (TypeError, ValueError):
|
|
88
|
+
expires_at = None
|
|
89
|
+
return cls(
|
|
90
|
+
access_token=access,
|
|
91
|
+
refresh_token=str(payload.get("refresh_token") or ""),
|
|
92
|
+
expires_at=expires_at,
|
|
93
|
+
scope=str(payload.get("scope") or ""),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def stale(self, now: float, skew: float = 300.0) -> bool:
|
|
97
|
+
"""True when this token should be refreshed. Unknown expiry is never stale."""
|
|
98
|
+
return self.expires_at is not None and self.expires_at - skew <= now
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class OAuthClient:
|
|
102
|
+
"""Drives the authorization-code + PKCE exchange against a configured provider."""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
client_id: str,
|
|
107
|
+
authorize_url: str,
|
|
108
|
+
token_url: str,
|
|
109
|
+
redirect_uri: str = "",
|
|
110
|
+
scopes: Optional[list] = None,
|
|
111
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
self.client_id = client_id
|
|
114
|
+
self.authorize_url = authorize_url
|
|
115
|
+
self.token_url = token_url
|
|
116
|
+
self.redirect_uri = redirect_uri
|
|
117
|
+
self.scopes = list(scopes or [])
|
|
118
|
+
self._client = client
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def configured(self) -> bool:
|
|
122
|
+
return bool(self.client_id and self.authorize_url and self.token_url)
|
|
123
|
+
|
|
124
|
+
def require_configured(self) -> None:
|
|
125
|
+
if self.configured:
|
|
126
|
+
return
|
|
127
|
+
missing = [
|
|
128
|
+
name
|
|
129
|
+
for name, value in (
|
|
130
|
+
("oauth.client_id", self.client_id),
|
|
131
|
+
("oauth.authorize_url", self.authorize_url),
|
|
132
|
+
("oauth.token_url", self.token_url),
|
|
133
|
+
)
|
|
134
|
+
if not value
|
|
135
|
+
]
|
|
136
|
+
raise OAuthError(
|
|
137
|
+
"subscription login is not configured: "
|
|
138
|
+
+ ", ".join(missing)
|
|
139
|
+
+ " must be set in tokenbiryani.yaml. Anthropic does not publish the OAuth "
|
|
140
|
+
"endpoints its first-party clients use, so this gateway will not guess at "
|
|
141
|
+
"them — see docs/oauth.md for how to find the values your client uses."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def authorize_url_for(self, login: PendingLogin) -> str:
|
|
145
|
+
self.require_configured()
|
|
146
|
+
query = {
|
|
147
|
+
"client_id": self.client_id,
|
|
148
|
+
"response_type": "code",
|
|
149
|
+
"code_challenge": challenge_for(login.verifier),
|
|
150
|
+
"code_challenge_method": "S256",
|
|
151
|
+
"state": login.state,
|
|
152
|
+
}
|
|
153
|
+
# A blank redirect_uri means the manual flow: the provider shows the code and
|
|
154
|
+
# the operator pastes it. That works even when we cannot register a callback.
|
|
155
|
+
if self.redirect_uri:
|
|
156
|
+
query["redirect_uri"] = self.redirect_uri
|
|
157
|
+
if self.scopes:
|
|
158
|
+
query["scope"] = " ".join(self.scopes)
|
|
159
|
+
separator = "&" if "?" in self.authorize_url else "?"
|
|
160
|
+
return self.authorize_url + separator + urlencode(query)
|
|
161
|
+
|
|
162
|
+
async def exchange(self, code: str, verifier: str) -> OAuthTokens:
|
|
163
|
+
"""Trade an authorization code for tokens."""
|
|
164
|
+
return await self._post({
|
|
165
|
+
"grant_type": "authorization_code",
|
|
166
|
+
"code": code,
|
|
167
|
+
"client_id": self.client_id,
|
|
168
|
+
"code_verifier": verifier,
|
|
169
|
+
**({"redirect_uri": self.redirect_uri} if self.redirect_uri else {}),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
async def refresh(self, refresh_token: str) -> OAuthTokens:
|
|
173
|
+
if not refresh_token:
|
|
174
|
+
raise OAuthError(
|
|
175
|
+
"this account has no refresh token, so its session cannot be renewed. "
|
|
176
|
+
"Log in again to replace it."
|
|
177
|
+
)
|
|
178
|
+
return await self._post({
|
|
179
|
+
"grant_type": "refresh_token",
|
|
180
|
+
"refresh_token": refresh_token,
|
|
181
|
+
"client_id": self.client_id,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
async def _post(self, form: Dict[str, str]) -> OAuthTokens:
|
|
185
|
+
self.require_configured()
|
|
186
|
+
client = self._client or httpx.AsyncClient(timeout=30.0)
|
|
187
|
+
owns = self._client is None
|
|
188
|
+
try:
|
|
189
|
+
response = await client.post(
|
|
190
|
+
self.token_url,
|
|
191
|
+
data=form,
|
|
192
|
+
headers={
|
|
193
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
194
|
+
"accept": "application/json",
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
except httpx.HTTPError as exc:
|
|
198
|
+
raise OAuthError(f"could not reach {self.token_url}: {exc}") from exc
|
|
199
|
+
finally:
|
|
200
|
+
if owns:
|
|
201
|
+
await client.aclose()
|
|
202
|
+
|
|
203
|
+
if response.status_code >= 400:
|
|
204
|
+
# OAuth errors are a documented shape; fall back to the raw body, capped,
|
|
205
|
+
# so a provider that returns HTML does not flood the console.
|
|
206
|
+
detail = response.text[:300]
|
|
207
|
+
try:
|
|
208
|
+
payload = response.json()
|
|
209
|
+
detail = str(
|
|
210
|
+
payload.get("error_description") or payload.get("error") or detail
|
|
211
|
+
)
|
|
212
|
+
except ValueError:
|
|
213
|
+
pass
|
|
214
|
+
raise OAuthError(f"{self.token_url} returned {response.status_code}: {detail}")
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
payload = response.json()
|
|
218
|
+
except ValueError as exc:
|
|
219
|
+
raise OAuthError(f"{self.token_url} did not return JSON: {exc}") from exc
|
|
220
|
+
if not isinstance(payload, dict):
|
|
221
|
+
raise OAuthError(f"{self.token_url} returned {type(payload).__name__}, not an object")
|
|
222
|
+
return OAuthTokens.from_response(payload, time.time())
|