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,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
|