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,173 @@
1
+ """Upstream protocol.
2
+
3
+ An upstream is anything that can serve a Messages request and report its limits.
4
+ Adding Bedrock or Vertex means implementing this and nothing else.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import abc
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, Mapping, Optional
12
+
13
+ import httpx
14
+
15
+ #: Headers we never forward from the client — ours to set, or hop-by-hop.
16
+ BLOCKED_REQUEST_HEADERS = frozenset(
17
+ {
18
+ "host", "content-length", "connection", "keep-alive", "transfer-encoding",
19
+ "upgrade", "te", "trailer", "proxy-authorization", "proxy-authenticate",
20
+ "authorization", "x-api-key", "accept-encoding",
21
+ }
22
+ )
23
+
24
+ #: Headers we never pass back to the client verbatim.
25
+ BLOCKED_RESPONSE_HEADERS = frozenset(
26
+ {"content-length", "content-encoding", "connection", "transfer-encoding", "keep-alive"}
27
+ )
28
+
29
+
30
+ @dataclass
31
+ class UpstreamResult:
32
+ status: int
33
+ headers: Dict[str, str] = field(default_factory=dict)
34
+ body: Optional[Dict[str, Any]] = None
35
+ raw: bytes = b""
36
+
37
+
38
+ def forwardable(headers: Mapping[str, str]) -> Dict[str, str]:
39
+ """Pass the client's request headers through, minus the ones we own.
40
+
41
+ The gateway is a router, not a rewriter: anything the client sent that isn't
42
+ ours to control travels untouched, including beta flags we've never heard of.
43
+ """
44
+ return {k: v for k, v in headers.items() if k.lower() not in BLOCKED_REQUEST_HEADERS}
45
+
46
+
47
+ def _as_result(response: httpx.Response, parse_json: bool = True) -> UpstreamResult:
48
+ body = None
49
+ if parse_json:
50
+ try:
51
+ parsed = response.json()
52
+ body = parsed if isinstance(parsed, dict) else None
53
+ except ValueError:
54
+ body = None
55
+ return UpstreamResult(
56
+ status=response.status_code,
57
+ headers=dict(response.headers),
58
+ body=body,
59
+ raw=response.content,
60
+ )
61
+
62
+
63
+ class Upstream(abc.ABC):
64
+ """One credential's worth of capacity."""
65
+
66
+ def __init__(self, account_id: str) -> None:
67
+ self.account_id = account_id
68
+
69
+ @abc.abstractmethod
70
+ def url(self, path: str) -> str:
71
+ ...
72
+
73
+ @abc.abstractmethod
74
+ def auth_headers(self) -> Dict[str, str]:
75
+ ...
76
+
77
+ def request_headers(self, client_headers: Mapping[str, str]) -> Dict[str, str]:
78
+ headers = forwardable(client_headers)
79
+ headers.update(self.auth_headers())
80
+ return headers
81
+
82
+ async def send(
83
+ self,
84
+ client: httpx.AsyncClient,
85
+ path: str,
86
+ payload: Mapping[str, Any],
87
+ client_headers: Mapping[str, str],
88
+ timeout: Optional[float] = None,
89
+ ) -> UpstreamResult:
90
+ response = await client.post(
91
+ self.url(path),
92
+ json=payload,
93
+ headers=self.request_headers(client_headers),
94
+ timeout=timeout,
95
+ )
96
+ body: Optional[Dict[str, Any]]
97
+ try:
98
+ parsed = response.json()
99
+ body = parsed if isinstance(parsed, dict) else None
100
+ except ValueError:
101
+ body = None
102
+ return UpstreamResult(
103
+ status=response.status_code,
104
+ headers=dict(response.headers),
105
+ body=body,
106
+ raw=response.content,
107
+ )
108
+
109
+ # ---- Message Batches ----------------------------------------------------
110
+ #
111
+ # The spill lane. Same credential, separate rate-limit pool upstream, so batch
112
+ # traffic does not eat the interactive budget the router is protecting.
113
+
114
+ async def submit_batch(
115
+ self,
116
+ client: httpx.AsyncClient,
117
+ requests: Any,
118
+ client_headers: Mapping[str, str],
119
+ ) -> UpstreamResult:
120
+ return await self.send(
121
+ client, "/v1/messages/batches", {"requests": requests}, client_headers
122
+ )
123
+
124
+ async def poll_batch(
125
+ self, client: httpx.AsyncClient, batch_id: str, client_headers: Mapping[str, str]
126
+ ) -> UpstreamResult:
127
+ response = await client.get(
128
+ self.url("/v1/messages/batches/" + batch_id),
129
+ headers=self.request_headers(client_headers),
130
+ )
131
+ return _as_result(response)
132
+
133
+ async def fetch_batch_results(
134
+ self, client: httpx.AsyncClient, batch_id: str, client_headers: Mapping[str, str]
135
+ ) -> UpstreamResult:
136
+ response = await client.get(
137
+ self.url("/v1/messages/batches/" + batch_id + "/results"),
138
+ headers=self.request_headers(client_headers),
139
+ )
140
+ return _as_result(response, parse_json=False)
141
+
142
+ async def cancel_batch(
143
+ self, client: httpx.AsyncClient, batch_id: str, client_headers: Mapping[str, str]
144
+ ) -> UpstreamResult:
145
+ return await self.send(
146
+ client, "/v1/messages/batches/" + batch_id + "/cancel", {}, client_headers
147
+ )
148
+
149
+ def iter_sse(self, response: httpx.Response):
150
+ """Yield the response as Anthropic SSE bytes.
151
+
152
+ The default is a straight passthrough, because that is what the request
153
+ already is. Bedrock is the exception: it frames its stream in AWS
154
+ event-stream binary, so its adapter re-emits SSE here.
155
+ """
156
+ return response.aiter_bytes()
157
+
158
+ def open_stream(
159
+ self,
160
+ client: httpx.AsyncClient,
161
+ path: str,
162
+ payload: Mapping[str, Any],
163
+ client_headers: Mapping[str, str],
164
+ timeout: Optional[float] = None,
165
+ ):
166
+ """Return an httpx streaming context manager; the caller drives it."""
167
+ return client.stream(
168
+ "POST",
169
+ self.url(path),
170
+ json=payload,
171
+ headers=self.request_headers(client_headers),
172
+ timeout=timeout,
173
+ )
@@ -0,0 +1,182 @@
1
+ """AWS Bedrock upstream.
2
+
3
+ Two things make Bedrock the awkward one. It addresses the model in the URL and signs
4
+ every request with SigV4, so the request cannot simply be forwarded. And it frames its
5
+ stream in AWS event-stream binary rather than SSE, so `iter_sse` decodes each frame
6
+ and re-emits the Anthropic event the client is expecting.
7
+
8
+ pip install "tokenbiryani[bedrock]"
9
+
10
+ accounts:
11
+ - id: acct-bedrock
12
+ type: bedrock
13
+ options:
14
+ region: us-east-1
15
+ profile: default # optional; omit for the ambient credential chain
16
+ model_map:
17
+ claude-test-1: anthropic.claude-3-5-sonnet-20241022-v2:0
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import json
24
+ from typing import Any, Dict, Mapping, Optional
25
+
26
+ import httpx
27
+
28
+ from ..config import AccountConfig
29
+ from .base import Upstream, UpstreamResult, _as_result
30
+ from .translate import BEDROCK_VERSION, map_model, to_platform_body
31
+
32
+ SERVICE = "bedrock"
33
+
34
+
35
+ class BedrockSigner:
36
+ """SigV4 over botocore. Signing needs the body, so it happens per request."""
37
+
38
+ def __init__(
39
+ self, region: str, profile: Optional[str] = None, credentials: Optional[Any] = None
40
+ ) -> None:
41
+ self.region = region
42
+ self.profile = profile
43
+ self._credentials = credentials
44
+
45
+ def credentials(self) -> Any:
46
+ if self._credentials is None:
47
+ try:
48
+ from botocore.session import Session
49
+ except ImportError as exc: # pragma: no cover - error path only
50
+ raise RuntimeError(
51
+ "the bedrock adapter needs botocore: "
52
+ "pip install 'tokenbiryani[bedrock]'"
53
+ ) from exc
54
+ session = Session(profile=self.profile) if self.profile else Session()
55
+ resolved = session.get_credentials()
56
+ if resolved is None:
57
+ raise RuntimeError(
58
+ "no AWS credentials found for the bedrock adapter"
59
+ )
60
+ self._credentials = resolved.get_frozen_credentials()
61
+ return self._credentials
62
+
63
+ def sign(self, url: str, body: bytes, headers: Mapping[str, str]) -> Dict[str, str]:
64
+ from botocore.auth import SigV4Auth
65
+ from botocore.awsrequest import AWSRequest
66
+
67
+ request = AWSRequest(method="POST", url=url, data=body, headers=dict(headers))
68
+ SigV4Auth(self.credentials(), SERVICE, self.region).add_auth(request)
69
+ return dict(request.headers)
70
+
71
+
72
+ class BedrockUpstream(Upstream):
73
+ def __init__(self, config: AccountConfig, signer: Optional[BedrockSigner] = None) -> None:
74
+ super().__init__(config.id)
75
+ self.config = config
76
+ options = config.options or {}
77
+ self.region = str(options.get("region") or "us-east-1")
78
+ self.model_map: Dict[str, str] = dict(options.get("model_map") or {})
79
+ self.base_url = (config.base_url or "").rstrip("/") or (
80
+ f"https://bedrock-runtime.{self.region}.amazonaws.com"
81
+ )
82
+ self._signer = signer or BedrockSigner(self.region, options.get("profile"))
83
+
84
+ # ---- addressing ----------------------------------------------------------
85
+
86
+ def model_url(self, model: str, stream: bool) -> str:
87
+ verb = "invoke-with-response-stream" if stream else "invoke"
88
+ return f"{self.base_url}/model/{map_model(model, self.model_map)}/{verb}"
89
+
90
+ def url(self, path: str) -> str:
91
+ return self.base_url + "/" + path.lstrip("/")
92
+
93
+ def auth_headers(self) -> Dict[str, str]:
94
+ # Signing produces the auth headers; nothing static to add.
95
+ return dict(self.config.headers)
96
+
97
+ def _headers(self, client_headers: Mapping[str, str], url: str, body: bytes):
98
+ headers = super().request_headers(client_headers)
99
+ headers.pop("anthropic-version", None)
100
+ headers["content-type"] = "application/json"
101
+ headers["host"] = httpx.URL(url).host
102
+ return self._signer.sign(url, body, headers)
103
+
104
+ # ---- transport -----------------------------------------------------------
105
+
106
+ async def send(
107
+ self,
108
+ client: httpx.AsyncClient,
109
+ path: str,
110
+ payload: Mapping[str, Any],
111
+ client_headers: Mapping[str, str],
112
+ timeout: Optional[float] = None,
113
+ ) -> UpstreamResult:
114
+ url = self.model_url(str(payload.get("model") or ""), stream=False)
115
+ body = json.dumps(to_platform_body(payload, BEDROCK_VERSION)).encode("utf-8")
116
+ response = await client.post(
117
+ url, content=body, headers=self._headers(client_headers, url, body), timeout=timeout
118
+ )
119
+ return _as_result(response)
120
+
121
+ def open_stream(
122
+ self,
123
+ client: httpx.AsyncClient,
124
+ path: str,
125
+ payload: Mapping[str, Any],
126
+ client_headers: Mapping[str, str],
127
+ timeout: Optional[float] = None,
128
+ ):
129
+ url = self.model_url(str(payload.get("model") or ""), stream=True)
130
+ body = json.dumps(to_platform_body(payload, BEDROCK_VERSION)).encode("utf-8")
131
+ return client.stream(
132
+ "POST",
133
+ url,
134
+ content=body,
135
+ headers=self._headers(client_headers, url, body),
136
+ timeout=timeout,
137
+ )
138
+
139
+ async def iter_sse(self, response: httpx.Response):
140
+ """Decode AWS event-stream frames and re-emit them as Anthropic SSE.
141
+
142
+ Each frame's payload is `{"bytes": "<base64 of one Anthropic event>"}`. The
143
+ gateway's stream machinery — the commit boundary, usage accounting — reads
144
+ ordinary SSE, so the translation stops here.
145
+ """
146
+ buffer = _event_stream_buffer()
147
+ async for chunk in response.aiter_bytes():
148
+ buffer.add_data(chunk)
149
+ for event in buffer:
150
+ frame = _to_sse(event)
151
+ if frame:
152
+ yield frame
153
+
154
+
155
+ def _event_stream_buffer():
156
+ try:
157
+ from botocore.eventstream import EventStreamBuffer
158
+ except ImportError as exc: # pragma: no cover - error path only
159
+ raise RuntimeError(
160
+ "the bedrock adapter needs botocore: pip install 'tokenbiryani[bedrock]'"
161
+ ) from exc
162
+ return EventStreamBuffer()
163
+
164
+
165
+ def _to_sse(event: Any) -> bytes:
166
+ payload = getattr(event, "payload", None)
167
+ if not payload:
168
+ return b""
169
+ try:
170
+ wrapper = json.loads(payload.decode("utf-8"))
171
+ except (ValueError, AttributeError):
172
+ return b""
173
+ encoded = wrapper.get("bytes")
174
+ if not encoded:
175
+ return b""
176
+ try:
177
+ inner = base64.b64decode(encoded)
178
+ parsed = json.loads(inner.decode("utf-8"))
179
+ except (ValueError, TypeError):
180
+ return b""
181
+ kind = parsed.get("type") or "message"
182
+ return f"event: {kind}\ndata: {json.dumps(parsed)}\n\n".encode()
@@ -0,0 +1,165 @@
1
+ """`type: oauth` — an account backed by a Claude subscription session.
2
+
3
+ The token normally comes from a login the gateway ran itself and holds per account,
4
+ which is what pooling more than one subscription requires. It can also come from a
5
+ credentials file or an environment variable — see `oauth_credentials` — for the case
6
+ where something else already obtained it.
7
+
8
+ **What an oauth account costs you:** a subscription session does not send the
9
+ `anthropic-ratelimit-{requests,input-tokens,output-tokens}-*` triples an API key
10
+ does, so `observable_limits` is forced false rather than left to the operator — an
11
+ account whose windows never populate would read as permanently full and win every
12
+ routing comparison.
13
+
14
+ It does send `anthropic-ratelimit-unified-*`: how much of the rolling 5-hour and
15
+ 7-day windows is spent. That is a real headroom number and the mirror reads it, so
16
+ these accounts do get headroom-aware routing. What stays dark is anything needing an
17
+ absolute token count: leases cannot reserve against a percentage, and the capacity
18
+ horizon cannot plot one. Failover and prompt-cache affinity are unaffected.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from typing import Any, Callable, Dict, Mapping, Optional
25
+
26
+ from ..config import AccountConfig
27
+ from .base import Upstream
28
+ from .oauth_credentials import CredentialError, StaticToken, TokenSource, build_source
29
+
30
+ #: Account options that name a token the gateway reads rather than owns. Any of
31
+ #: these means "do not use a gateway-managed session for this account".
32
+ EXTERNAL_SOURCE_OPTIONS = ("access_token", "token_env", "credentials_path")
33
+
34
+ logger = logging.getLogger("tokenbiryani")
35
+
36
+ DEFAULT_BASE_URL = "https://api.anthropic.com"
37
+
38
+ #: Unverified, and deliberately configurable. This project has never been run against
39
+ #: a real subscription session, so this is a starting value rather than a fact. If the
40
+ #: gateway logs 4xx mentioning a beta flag, set `options.beta_header`.
41
+ DEFAULT_BETA = "oauth-2025-04-20"
42
+
43
+ ANTHROPIC_VERSION = "2023-06-01"
44
+
45
+
46
+ class OAuthUpstream(Upstream):
47
+ """Bearer-token upstream. The token is supplied by a callback, not held here.
48
+
49
+ The gateway owns token lifecycle — storage, refresh, expiry — so this class asks
50
+ for the current one on each request rather than caching a copy that could go
51
+ stale behind a successful refresh.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ config: AccountConfig,
57
+ token_provider: Optional[Callable[[], str]] = None,
58
+ ) -> None:
59
+ super().__init__(config.id)
60
+ self.config = config
61
+ options: Dict[str, Any] = dict(config.options or {})
62
+ self.base_url = (config.base_url or DEFAULT_BASE_URL).rstrip("/")
63
+ self.beta = str(options.get("beta_header") or DEFAULT_BETA)
64
+
65
+ # Two ways an oauth account gets a token, and explicit config wins.
66
+ #
67
+ # `type: oauth` originally meant only "read the token the Claude CLI already
68
+ # wrote". Those configs keep working verbatim, so any account naming a source
69
+ # keeps using it; only accounts with no source configured use the session the
70
+ # gateway logged in for and refreshes.
71
+ self._source: Optional[TokenSource] = None
72
+ if any(options.get(k) for k in EXTERNAL_SOURCE_OPTIONS):
73
+ self._source = build_source(options)
74
+ elif config.api_key:
75
+ # A token pasted into the console — `claude setup-token` mints one that
76
+ # outlives a CLI session. It arrives in `api_key` rather than in options
77
+ # because that is the field the store encrypts and the admin API strips
78
+ # by name; a secret in `options` would be neither.
79
+ self._source = StaticToken(config.api_key)
80
+ self._token_provider = token_provider
81
+
82
+ if config.observable_limits:
83
+ # Not merely cosmetic: an account whose classic windows never populate
84
+ # reads as full, wins every comparison against accounts reporting an
85
+ # honest partial budget, and absorbs the pool's traffic until it 429s.
86
+ # Its real budget arrives as unified utilisation, which the mirror reads
87
+ # whatever this flag says.
88
+ logger.warning(
89
+ "account %r is an oauth account but observable_limits is true. "
90
+ "Subscription sessions send no per-window limit/remaining headers, so "
91
+ "its headroom would read as permanently full. Forcing it false — its "
92
+ "unified utilisation is read either way.",
93
+ config.id,
94
+ )
95
+ config.observable_limits = False
96
+
97
+ def token(self) -> str:
98
+ if self._source is not None:
99
+ # CredentialError propagates: the gateway classifies it as a transport
100
+ # failure, and the reason lands in the request inspector.
101
+ return self._source.token()
102
+ if self._token_provider is None:
103
+ raise RuntimeError(
104
+ f"account {self.account_id!r} has no token: log in from the console, "
105
+ "point it at this machine's Claude Code session, paste a token from "
106
+ "`claude setup-token`, or set options.credentials_path / "
107
+ "options.token_env / options.access_token"
108
+ )
109
+ return self._token_provider()
110
+
111
+ def describe_source(self) -> str:
112
+ if self._source is not None:
113
+ return self._source.describe()
114
+ return "session held by the gateway"
115
+
116
+ def source_expiry(self) -> Optional[float]:
117
+ """When the token this account reads goes stale, if the source knows.
118
+
119
+ A credentials file states its own expiry, and showing it is the difference
120
+ between "this will stop working at 21:56 unless the CLI renews it" and a
121
+ 401 nobody expected.
122
+ """
123
+ getter = getattr(self._source, "expires_at", None)
124
+ if getter is None:
125
+ return None
126
+ try:
127
+ return getter()
128
+ except CredentialError:
129
+ return None
130
+
131
+ @property
132
+ def source_kind(self) -> str:
133
+ """Which of the four token sources this account is using, for the console."""
134
+ if isinstance(self._source, StaticToken):
135
+ return "token"
136
+ if self._source is not None:
137
+ return "external"
138
+ return "login"
139
+
140
+ def url(self, path: str) -> str:
141
+ return self.base_url + "/" + path.lstrip("/")
142
+
143
+ def auth_headers(self) -> Dict[str, str]:
144
+ headers = {
145
+ "authorization": "Bearer " + self.token(),
146
+ "content-type": "application/json",
147
+ "anthropic-beta": self.beta,
148
+ "anthropic-version": ANTHROPIC_VERSION,
149
+ }
150
+ headers.update(self.config.headers)
151
+ return headers
152
+
153
+ def request_headers(self, client_headers: Mapping[str, str]) -> Dict[str, str]:
154
+ headers = super().request_headers(client_headers)
155
+ # A subscription session authenticates with a bearer token; an x-api-key
156
+ # forwarded from the caller would be ignored at best and confusing at worst.
157
+ for name in [k for k in headers if k.lower() == "x-api-key"]:
158
+ headers.pop(name)
159
+ client_beta = next(
160
+ (v for k, v in client_headers.items() if k.lower() == "anthropic-beta"), None
161
+ )
162
+ if client_beta and self.beta not in client_beta:
163
+ # Keep the caller's beta flags and add ours.
164
+ headers["anthropic-beta"] = client_beta + "," + self.beta
165
+ return headers