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.
Files changed (51) hide show
  1. tokenbiryani/__init__.py +3 -0
  2. tokenbiryani/api/__init__.py +0 -0
  3. tokenbiryani/api/app.py +583 -0
  4. tokenbiryani/api/asgi.py +32 -0
  5. tokenbiryani/cli.py +1045 -0
  6. tokenbiryani/config.py +532 -0
  7. tokenbiryani/core/__init__.py +0 -0
  8. tokenbiryani/core/account.py +258 -0
  9. tokenbiryani/core/batch.py +135 -0
  10. tokenbiryani/core/breaker.py +53 -0
  11. tokenbiryani/core/cacheadvice.py +239 -0
  12. tokenbiryani/core/diagnostics.py +131 -0
  13. tokenbiryani/core/estimator.py +180 -0
  14. tokenbiryani/core/gateway.py +2395 -0
  15. tokenbiryani/core/handoff.py +87 -0
  16. tokenbiryani/core/keys.py +199 -0
  17. tokenbiryani/core/limits.py +440 -0
  18. tokenbiryani/core/oauth.py +222 -0
  19. tokenbiryani/core/pacing.py +320 -0
  20. tokenbiryani/core/queue.py +132 -0
  21. tokenbiryani/core/router.py +323 -0
  22. tokenbiryani/core/secrets.py +114 -0
  23. tokenbiryani/core/session.py +117 -0
  24. tokenbiryani/dashboard/__init__.py +56 -0
  25. tokenbiryani/dashboard/console.css +610 -0
  26. tokenbiryani/dashboard/console.html +3250 -0
  27. tokenbiryani/observability/__init__.py +0 -0
  28. tokenbiryani/observability/events.py +171 -0
  29. tokenbiryani/observability/usage.py +226 -0
  30. tokenbiryani/prices.yaml +77 -0
  31. tokenbiryani/providers/__init__.py +0 -0
  32. tokenbiryani/providers/anthropic_api.py +118 -0
  33. tokenbiryani/providers/base.py +173 -0
  34. tokenbiryani/providers/bedrock.py +182 -0
  35. tokenbiryani/providers/oauth.py +165 -0
  36. tokenbiryani/providers/oauth_credentials.py +293 -0
  37. tokenbiryani/providers/translate.py +35 -0
  38. tokenbiryani/providers/vertex.py +144 -0
  39. tokenbiryani/proxy/__init__.py +0 -0
  40. tokenbiryani/proxy/errors.py +169 -0
  41. tokenbiryani/proxy/sse.py +98 -0
  42. tokenbiryani/store/__init__.py +0 -0
  43. tokenbiryani/store/base.py +150 -0
  44. tokenbiryani/store/memory.py +149 -0
  45. tokenbiryani/store/redis_store.py +222 -0
  46. tokenbiryani/store/sqlite.py +336 -0
  47. tokenbiryani-0.2.0.dist-info/METADATA +697 -0
  48. tokenbiryani-0.2.0.dist-info/RECORD +51 -0
  49. tokenbiryani-0.2.0.dist-info/WHEEL +4 -0
  50. tokenbiryani-0.2.0.dist-info/entry_points.txt +2 -0
  51. 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())