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
File without changes
@@ -0,0 +1,171 @@
1
+ """Request events: the one stream the console, the CLI and the logs all read from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import time
9
+ from collections import deque
10
+ from dataclasses import asdict, dataclass, field
11
+ from typing import Any, Deque, Dict, List, Optional, Set
12
+
13
+ logger = logging.getLogger("tokenbiryani")
14
+
15
+
16
+ @dataclass
17
+ class Attempt:
18
+ account_id: str
19
+ status: Optional[int] = None
20
+ kind: str = ""
21
+ latency: float = 0.0
22
+ detail: str = ""
23
+
24
+ def to_dict(self) -> Dict[str, Any]:
25
+ return asdict(self)
26
+
27
+
28
+ @dataclass
29
+ class RequestEvent:
30
+ request_id: str
31
+ started_at: float
32
+ model: str = ""
33
+ key_name: str = ""
34
+ session_key: str = ""
35
+ streamed: bool = False
36
+ priority: str = "interactive"
37
+ #: The model the caller asked for, when pacing substituted a different one.
38
+ #: Empty when nothing was substituted, which is the normal case.
39
+ model_requested: str = ""
40
+ #: "messages" (the normal path) or "batch" (spilled to the Batches API)
41
+ via: str = "messages"
42
+ batch_id: Optional[str] = None
43
+ attempts: List[Attempt] = field(default_factory=list)
44
+ account_id: Optional[str] = None
45
+ status: Optional[int] = None
46
+ latency: float = 0.0
47
+ ttft: Optional[float] = None
48
+ queued_for: float = 0.0
49
+ #: Seconds this request was deliberately held back to protect a quota window.
50
+ #: Batch priority only, and only when pacing is enforcing.
51
+ paced_for: float = 0.0
52
+ input_tokens: int = 0
53
+ output_tokens: int = 0
54
+ cache_read_tokens: int = 0
55
+ cache_creation_tokens: int = 0
56
+ cost_usd: Optional[float] = None
57
+ saved_usd: Optional[float] = None
58
+ affinity_broken: bool = False
59
+ affinity_honored: bool = False
60
+ #: Whether the caller marked anything with `cache_control`. None when the
61
+ #: request never got far enough to be inspected.
62
+ cache_breakpoint: Optional[bool] = None
63
+ #: Rough size of the stable head — the part a breakpoint would cover.
64
+ prefix_tokens: int = 0
65
+ decision: Optional[Dict[str, Any]] = None
66
+ error: Optional[str] = None
67
+
68
+ @property
69
+ def cache_hit(self) -> bool:
70
+ return self.cache_read_tokens > 0
71
+
72
+ def to_dict(self) -> Dict[str, Any]:
73
+ data = asdict(self)
74
+ data["attempts"] = [a.to_dict() for a in self.attempts]
75
+ data["cache_hit"] = self.cache_hit
76
+ return data
77
+
78
+
79
+ class EventLog:
80
+ """A bounded ring buffer plus fan-out to live subscribers (the console's SSE feed)."""
81
+
82
+ def __init__(self, capacity: int = 500) -> None:
83
+ self._events: Deque[RequestEvent] = deque(maxlen=capacity)
84
+ self._subscribers: Set[asyncio.Queue[Dict[str, Any]]] = set()
85
+
86
+ def record(self, event: RequestEvent) -> None:
87
+ self._events.append(event)
88
+ payload = event.to_dict()
89
+ for queue in list(self._subscribers):
90
+ try:
91
+ queue.put_nowait(payload)
92
+ except asyncio.QueueFull:
93
+ # A slow console must never apply backpressure to a request.
94
+ pass
95
+ logger.info("request %s", json.dumps(_log_view(payload), separators=(",", ":")))
96
+
97
+ def recent(self, limit: int = 50) -> List[Dict[str, Any]]:
98
+ events = list(self._events)[-limit:]
99
+ return [e.to_dict() for e in reversed(events)]
100
+
101
+ def recent_for(self, account_id: str, limit: int = 50) -> List[Dict[str, Any]]:
102
+ """Requests this account took part in, including attempts it failed."""
103
+ matched = [
104
+ event
105
+ for event in self._events
106
+ if event.account_id == account_id
107
+ or any(attempt.account_id == account_id for attempt in event.attempts)
108
+ ]
109
+ return [e.to_dict() for e in reversed(matched[-limit:])]
110
+
111
+ def get(self, request_id: str) -> Optional[Dict[str, Any]]:
112
+ for event in reversed(self._events):
113
+ if event.request_id == request_id:
114
+ return event.to_dict()
115
+ return None
116
+
117
+ def stats(self, window_seconds: float = 3600.0) -> Dict[str, Any]:
118
+ cutoff = time.time() - window_seconds
119
+ recent = [e for e in self._events if e.started_at >= cutoff]
120
+ failovers = sum(1 for e in recent if len(e.attempts) > 1)
121
+ breaks = sum(1 for e in recent if e.affinity_broken)
122
+ errors = sum(1 for e in recent if e.status is None or e.status >= 400)
123
+ billed = sum(
124
+ e.input_tokens + e.cache_read_tokens + e.cache_creation_tokens for e in recent
125
+ )
126
+ cached = sum(e.cache_read_tokens for e in recent)
127
+ spend = sum(e.cost_usd or 0.0 for e in recent)
128
+ return {
129
+ "window_seconds": window_seconds,
130
+ "requests": len(recent),
131
+ "failovers": failovers,
132
+ "cache_breaks": breaks,
133
+ "errors": errors,
134
+ "cache_hit_rate": round(cached / billed, 4) if billed else None,
135
+ "spend_usd": round(spend, 4),
136
+ }
137
+
138
+ def subscribe(self) -> asyncio.Queue[Dict[str, Any]]:
139
+ queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue(maxsize=64)
140
+ self._subscribers.add(queue)
141
+ return queue
142
+
143
+ def unsubscribe(self, queue: asyncio.Queue[Dict[str, Any]]) -> None:
144
+ self._subscribers.discard(queue)
145
+
146
+
147
+ def _log_view(payload: Dict[str, Any]) -> Dict[str, Any]:
148
+ """Structured log line. Prompts are never in here — only accounting."""
149
+ return {
150
+ "request_id": payload["request_id"],
151
+ "model": payload["model"],
152
+ "key": payload["key_name"],
153
+ "account": payload["account_id"],
154
+ "status": payload["status"],
155
+ "latency": round(payload["latency"], 3),
156
+ "attempts": [
157
+ {"account": a["account_id"], "status": a["status"], "kind": a["kind"]}
158
+ for a in payload["attempts"]
159
+ ],
160
+ "tokens": {
161
+ "in": payload["input_tokens"],
162
+ "out": payload["output_tokens"],
163
+ "cache_read": payload["cache_read_tokens"],
164
+ },
165
+ "cache_hit": payload["cache_hit"],
166
+ "priority": payload["priority"],
167
+ "via": payload["via"],
168
+ "queued_for": payload["queued_for"],
169
+ "affinity_broken": payload["affinity_broken"],
170
+ "cost_usd": payload["cost_usd"],
171
+ }
@@ -0,0 +1,226 @@
1
+ """Persisted usage history, and the bucketing that turns it into charts.
2
+
3
+ The event log (`events.py`) is a 500-entry ring in memory: perfect for "what is
4
+ happening right now", useless for "what did last Tuesday cost". Charts need the
5
+ second question answered, and answering it means writing a row per request and
6
+ keeping it.
7
+
8
+ Two decisions worth stating.
9
+
10
+ **One row per request, like the spend ledger.** Not a pre-aggregated rollup. A
11
+ rollup has to choose its buckets at write time, and every question you did not
12
+ anticipate — cost by model, cache rate for one account, errors in a five minute
13
+ window — becomes unanswerable. Rows are cheap and the retention window is bounded.
14
+
15
+ **Aggregation lives here, not in the backends.** Each store only has to append a
16
+ row and hand back the rows in a window; the bucketing, the grouping and every
17
+ derived rate are computed once, in `aggregate`. Three backends implementing the
18
+ same arithmetic three times is three chances to disagree, and a chart that differs
19
+ by backend is worse than no chart.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Dict, Iterable, List, Optional
25
+
26
+ #: What a stored row holds. Deliberately flat and JSON-safe: SQLite gets columns,
27
+ #: Redis and memory get the dict verbatim.
28
+ USAGE_FIELDS = (
29
+ "at",
30
+ "account_id",
31
+ "key_name",
32
+ "model",
33
+ "status",
34
+ "input_tokens",
35
+ "output_tokens",
36
+ "cache_read_tokens",
37
+ "cache_creation_tokens",
38
+ "cost_usd",
39
+ "latency",
40
+ "affinity_broken",
41
+ "via",
42
+ "error",
43
+ )
44
+
45
+ #: How a `group_by` name maps onto a row field.
46
+ GROUP_FIELDS = {
47
+ "account": "account_id",
48
+ "model": "model",
49
+ "key": "key_name",
50
+ }
51
+
52
+ #: Windows the console offers, in seconds.
53
+ WINDOWS = {"1h": 3600, "24h": 86400, "7d": 604800, "30d": 2592000}
54
+
55
+ #: A readable bucket for each, chosen so a chart lands between 12 and 60 marks.
56
+ DEFAULT_BUCKETS = {3600: 300, 86400: 3600, 604800: 21600, 2592000: 86400}
57
+
58
+
59
+ def sample_from_event(event: Any) -> Dict[str, Any]:
60
+ """Flatten a `RequestEvent` into a storable row.
61
+
62
+ Failed requests are recorded too — a chart that only shows successes hides the
63
+ outage, which is the thing you opened the chart to see.
64
+ """
65
+ return {
66
+ "at": float(event.started_at),
67
+ "account_id": event.account_id or "",
68
+ "key_name": event.key_name or "",
69
+ "model": event.model or "",
70
+ "status": int(event.status) if event.status is not None else 0,
71
+ "input_tokens": int(event.input_tokens or 0),
72
+ "output_tokens": int(event.output_tokens or 0),
73
+ "cache_read_tokens": int(event.cache_read_tokens or 0),
74
+ "cache_creation_tokens": int(event.cache_creation_tokens or 0),
75
+ "cost_usd": float(event.cost_usd) if event.cost_usd is not None else 0.0,
76
+ "latency": float(event.latency or 0.0),
77
+ "affinity_broken": 1 if event.affinity_broken else 0,
78
+ "via": event.via or "messages",
79
+ "error": event.error or "",
80
+ }
81
+
82
+
83
+ def resolve_window(raw: Optional[str], fallback: int = 86400) -> int:
84
+ """Accept either a label the console sends (`24h`) or a raw second count."""
85
+ if raw is None:
86
+ return fallback
87
+ text = str(raw).strip().lower()
88
+ if text in WINDOWS:
89
+ return WINDOWS[text]
90
+ try:
91
+ seconds = int(float(text))
92
+ except ValueError:
93
+ return fallback
94
+ # A window of a year of per-request rows is a denial of service on yourself.
95
+ return max(60, min(seconds, WINDOWS["30d"]))
96
+
97
+
98
+ def resolve_bucket(window_seconds: int, raw: Optional[str]) -> int:
99
+ if raw is not None:
100
+ try:
101
+ requested = int(float(raw))
102
+ except ValueError:
103
+ requested = 0
104
+ if requested > 0:
105
+ # Never fewer than 2 or more than 240 marks: past that a line chart is
106
+ # either a single dot or an unreadable comb.
107
+ return max(window_seconds // 240, min(requested, window_seconds // 2))
108
+ for size, bucket in sorted(DEFAULT_BUCKETS.items()):
109
+ if window_seconds <= size:
110
+ return bucket
111
+ return DEFAULT_BUCKETS[2592000]
112
+
113
+
114
+ def _blank() -> Dict[str, Any]:
115
+ return {
116
+ "requests": 0,
117
+ "errors": 0,
118
+ "input_tokens": 0,
119
+ "output_tokens": 0,
120
+ "cache_read_tokens": 0,
121
+ "cache_creation_tokens": 0,
122
+ "cost_usd": 0.0,
123
+ "cache_breaks": 0,
124
+ "_latency_sum": 0.0,
125
+ }
126
+
127
+
128
+ def _add(cell: Dict[str, Any], row: Dict[str, Any]) -> None:
129
+ cell["requests"] += 1
130
+ status = int(row.get("status") or 0)
131
+ if row.get("error") or status == 0 or status >= 400:
132
+ cell["errors"] += 1
133
+ cell["input_tokens"] += int(row.get("input_tokens") or 0)
134
+ cell["output_tokens"] += int(row.get("output_tokens") or 0)
135
+ cell["cache_read_tokens"] += int(row.get("cache_read_tokens") or 0)
136
+ cell["cache_creation_tokens"] += int(row.get("cache_creation_tokens") or 0)
137
+ cell["cost_usd"] += float(row.get("cost_usd") or 0.0)
138
+ cell["cache_breaks"] += 1 if row.get("affinity_broken") else 0
139
+ cell["_latency_sum"] += float(row.get("latency") or 0.0)
140
+
141
+
142
+ def _finalise(cell: Dict[str, Any]) -> Dict[str, Any]:
143
+ """Turn running sums into the derived rates a chart actually plots."""
144
+ billed = (
145
+ cell["input_tokens"] + cell["cache_read_tokens"] + cell["cache_creation_tokens"]
146
+ )
147
+ requests = cell["requests"]
148
+ out = {k: v for k, v in cell.items() if not k.startswith("_")}
149
+ out["billed_input_tokens"] = billed
150
+ out["total_tokens"] = billed + cell["output_tokens"]
151
+ out["cost_usd"] = round(cell["cost_usd"], 6)
152
+ # None, not 0.0: "no traffic" and "every request missed the cache" are different
153
+ # answers, and a chart that draws them the same is lying about the quiet hours.
154
+ out["cache_hit_rate"] = (
155
+ round(cell["cache_read_tokens"] / billed, 4) if billed else None
156
+ )
157
+ out["latency_avg"] = round(cell["_latency_sum"] / requests, 4) if requests else None
158
+ return out
159
+
160
+
161
+ def aggregate(
162
+ rows: Iterable[Dict[str, Any]],
163
+ now: float,
164
+ window_seconds: int,
165
+ bucket_seconds: int,
166
+ group_by: str = "account",
167
+ ) -> Dict[str, Any]:
168
+ """Bucket raw usage rows into a time series plus totals.
169
+
170
+ Buckets are emitted for the whole window even when empty, so a gap in traffic
171
+ draws as a gap rather than silently compressing the time axis.
172
+ """
173
+ field = GROUP_FIELDS.get(group_by, "account_id")
174
+ count = max(1, int(window_seconds // bucket_seconds))
175
+ # Anchor to the bucket grid so the last bucket is the live, partial one and
176
+ # every earlier bucket covers a full period.
177
+ end = (int(now) // bucket_seconds) * bucket_seconds + bucket_seconds
178
+ start = end - count * bucket_seconds
179
+
180
+ buckets: List[Dict[str, Any]] = [
181
+ {"at": start + index * bucket_seconds, "_all": _blank(), "_by": {}}
182
+ for index in range(count)
183
+ ]
184
+ totals: Dict[str, Dict[str, Any]] = {}
185
+ overall = _blank()
186
+
187
+ for row in rows:
188
+ at = float(row.get("at") or 0.0)
189
+ if at < start or at >= end:
190
+ continue
191
+ index = int((at - start) // bucket_seconds)
192
+ if index < 0 or index >= count:
193
+ continue
194
+ name = str(row.get(field) or "—")
195
+ bucket = buckets[index]
196
+ _add(bucket["_all"], row)
197
+ if name not in bucket["_by"]:
198
+ bucket["_by"][name] = _blank()
199
+ _add(bucket["_by"][name], row)
200
+ if name not in totals:
201
+ totals[name] = _blank()
202
+ _add(totals[name], row)
203
+ _add(overall, row)
204
+
205
+ # Largest first: the legend order and the stacking order should agree, and the
206
+ # series worth reading should be the one nearest the axis.
207
+ order = sorted(totals, key=lambda n: -totals[n]["input_tokens"] - totals[n]["output_tokens"])
208
+
209
+ return {
210
+ "window_seconds": window_seconds,
211
+ "bucket_seconds": bucket_seconds,
212
+ "group_by": group_by,
213
+ "start": start,
214
+ "end": end,
215
+ "groups": order,
216
+ "buckets": [
217
+ {
218
+ "at": bucket["at"],
219
+ "all": _finalise(bucket["_all"]),
220
+ "by": {name: _finalise(cell) for name, cell in bucket["_by"].items()},
221
+ }
222
+ for bucket in buckets
223
+ ],
224
+ "totals": {name: _finalise(cell) for name, cell in totals.items()},
225
+ "overall": _finalise(overall),
226
+ }
@@ -0,0 +1,77 @@
1
+ # Bundled price table — USD per million tokens.
2
+ #
3
+ # This file is DATA, not code, and it is DATED. That distinction is the whole
4
+ # argument for shipping it: the project's standing rule is that the gateway must
5
+ # never bill against a number nobody can attribute, and a dated table that the
6
+ # console displays the date of satisfies that rule. A hard-coded dict in
7
+ # config.py would not.
8
+ #
9
+ # Opt in with `pricing: builtin` in tokenbiryani.yaml. Name a model under
10
+ # `pricing:` to override any line here — your entry always wins.
11
+ #
12
+ # Check the current numbers at https://anthropic.com/pricing and either update
13
+ # this file or override in your own config. The console shows `as_of` beside
14
+ # every cost so a stale table is visible rather than silent.
15
+ #
16
+ # cache_read and cache_write are the standard multiples of the input rate:
17
+ # a cache read bills at ~0.1x input, a 5-minute cache write at ~1.25x input.
18
+ # Claude Fable 5.1 is the exception — its cache reads are priced at a flat
19
+ # $0.25/MTok rather than 0.1x — so it is written out rather than derived.
20
+
21
+ as_of: 2026-06-24
22
+ source: https://anthropic.com/pricing
23
+
24
+ models:
25
+ claude-fable-5-1*:
26
+ input: 10.0
27
+ output: 50.0
28
+ cache_read: 0.25
29
+ cache_write: 12.5
30
+
31
+ claude-fable-5*:
32
+ input: 10.0
33
+ output: 50.0
34
+ cache_read: 1.0
35
+ cache_write: 12.5
36
+
37
+ claude-opus-5*:
38
+ input: 5.0
39
+ output: 25.0
40
+ cache_read: 0.5
41
+ cache_write: 6.25
42
+
43
+ claude-opus-4-8*:
44
+ input: 5.0
45
+ output: 25.0
46
+ cache_read: 0.5
47
+ cache_write: 6.25
48
+
49
+ claude-opus-4-7*:
50
+ input: 5.0
51
+ output: 25.0
52
+ cache_read: 0.5
53
+ cache_write: 6.25
54
+
55
+ claude-opus-4-6*:
56
+ input: 5.0
57
+ output: 25.0
58
+ cache_read: 0.5
59
+ cache_write: 6.25
60
+
61
+ claude-sonnet-5*:
62
+ input: 2.0
63
+ output: 10.0
64
+ cache_read: 0.2
65
+ cache_write: 2.5
66
+
67
+ claude-sonnet-4-6*:
68
+ input: 3.0
69
+ output: 15.0
70
+ cache_read: 0.3
71
+ cache_write: 3.75
72
+
73
+ claude-haiku-4-5*:
74
+ input: 1.0
75
+ output: 5.0
76
+ cache_read: 0.1
77
+ cache_write: 1.25
File without changes
@@ -0,0 +1,118 @@
1
+ """Anthropic API key upstream — the supported path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Callable, Dict, List, Optional
7
+
8
+ from ..config import AccountConfig
9
+ from .base import Upstream
10
+
11
+ logger = logging.getLogger("tokenbiryani")
12
+
13
+ _plugins: Dict[str, Callable[[AccountConfig], Upstream]] = {}
14
+ _plugins_loaded = False
15
+
16
+
17
+ def register_upstream(name: str, factory: Callable[[AccountConfig], Upstream]) -> None:
18
+ """Register an account type at runtime. Built-in names cannot be shadowed."""
19
+ if name in BUILTIN_TYPES:
20
+ raise ValueError(f"{name!r} is a built-in account type and cannot be replaced")
21
+ _plugins[name] = factory
22
+
23
+
24
+ def plugin_upstreams() -> Dict[str, Callable[[AccountConfig], Upstream]]:
25
+ global _plugins_loaded
26
+ if _plugins_loaded:
27
+ return _plugins
28
+ _plugins_loaded = True
29
+ for entry in _entry_points(PLUGIN_GROUP):
30
+ try:
31
+ register_upstream(entry.name, entry.load())
32
+ except Exception as exc: # noqa: BLE001 - a bad plugin must not be fatal
33
+ logger.warning("could not load account type %r: %s", entry.name, exc)
34
+ return _plugins
35
+
36
+
37
+ def _entry_points(group: str):
38
+ """importlib.metadata changed shape in 3.10; support both."""
39
+ from importlib.metadata import entry_points
40
+
41
+ found = entry_points()
42
+ selector = getattr(found, "select", None)
43
+ if selector is not None:
44
+ return list(selector(group=group))
45
+ return list(found.get(group, []))
46
+
47
+
48
+ def available_types() -> List[str]:
49
+ return sorted(set(BUILTIN_TYPES) | set(plugin_upstreams()))
50
+
51
+ #: Entry point group third-party packages publish account types under.
52
+ PLUGIN_GROUP = "tokenbiryani.providers"
53
+
54
+ BUILTIN_TYPES = ("anthropic_api", "bedrock", "vertex", "oauth")
55
+
56
+ ANTHROPIC_VERSION = "2023-06-01"
57
+ DEFAULT_BASE_URL = "https://api.anthropic.com"
58
+
59
+
60
+ class AnthropicUpstream(Upstream):
61
+ def __init__(self, config: AccountConfig) -> None:
62
+ super().__init__(config.id)
63
+ self.config = config
64
+ self.base_url = (config.base_url or DEFAULT_BASE_URL).rstrip("/")
65
+
66
+ def url(self, path: str) -> str:
67
+ return self.base_url + "/" + path.lstrip("/")
68
+
69
+ def auth_headers(self) -> Dict[str, str]:
70
+ headers = {
71
+ "x-api-key": self.config.api_key,
72
+ "content-type": "application/json",
73
+ }
74
+ headers.update(self.config.headers)
75
+ headers.setdefault("anthropic-version", ANTHROPIC_VERSION)
76
+ return headers
77
+
78
+ def request_headers(self, client_headers):
79
+ headers = super().request_headers(client_headers)
80
+ # The client's own anthropic-version wins if it sent one; ours is only a default.
81
+ for name in list(headers):
82
+ if name.lower() == "anthropic-version" and name != "anthropic-version":
83
+ headers["anthropic-version"] = headers.pop(name)
84
+ return headers
85
+
86
+
87
+ def build_upstream(
88
+ config: AccountConfig, token_provider: Optional[Callable[[], str]] = None
89
+ ) -> Upstream:
90
+ """Construct the upstream for an account type, built-in or installed.
91
+
92
+ `token_provider` is only meaningful for `oauth` accounts, whose credential is a
93
+ session the gateway refreshes rather than a value sitting in the config.
94
+ """
95
+ if config.type in ("anthropic_api", "anthropic"):
96
+ return AnthropicUpstream(config)
97
+ if config.type == "oauth":
98
+ from .oauth import OAuthUpstream
99
+
100
+ return OAuthUpstream(config, token_provider)
101
+ if config.type == "bedrock":
102
+ from .bedrock import BedrockUpstream
103
+
104
+ return BedrockUpstream(config)
105
+ if config.type == "vertex":
106
+ from .vertex import VertexUpstream
107
+
108
+ return VertexUpstream(config)
109
+
110
+ factory = plugin_upstreams().get(config.type)
111
+ if factory is not None:
112
+ return factory(config)
113
+
114
+ raise ValueError(
115
+ f"unsupported account type {config.type!r}; known: "
116
+ f"{', '.join(available_types())}. Implement providers.base.Upstream and "
117
+ f"publish it under the {PLUGIN_GROUP!r} entry point to add another."
118
+ )