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,320 @@
1
+ """Spending a quota window on purpose instead of by accident.
2
+
3
+ Everything else here answers "is there capacity right now". Nothing answers "should
4
+ this be spent now", and those are different questions. A pool that burns its week by
5
+ Wednesday and one that reaches Sunday with a third of the quota unused are both
6
+ failures, and neither shows up in a headroom meter — headroom is full again after
7
+ every reset, right up until the window it belongs to runs out.
8
+
9
+ Two signals, and they are not interchangeable:
10
+
11
+ **A subscription reports its own window.** `anthropic-ratelimit-unified-7d-*` carries
12
+ utilisation and a reset, so the pace is measured rather than declared. This is the
13
+ good path and the one this feature was built for.
14
+
15
+ **An API key has no weekly window at all.** Its limits are per-minute, and no header
16
+ says anything about a week. So pacing there compares spend since the start of the
17
+ calendar week against a budget the operator states, and if they state none there is
18
+ no pacing — an invented weekly limit would be a number nobody can attribute, which
19
+ is the same reason this project ships no price list.
20
+
21
+ Advisory by default. Enforcement throttles `batch` priority only: degrading someone's
22
+ interactive session to hit a budget figure is a worse outcome than missing the figure,
23
+ and an operator who wanted that could set a spend cap instead.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import datetime as _dt
29
+ from dataclasses import dataclass
30
+ from typing import Any, Dict, List, Optional
31
+
32
+ WINDOW_SECONDS = {"5h": 5 * 3600.0, "7d": 7 * 24 * 3600.0}
33
+
34
+ SOURCE_UNIFIED = "unified"
35
+ SOURCE_BUDGET = "budget"
36
+
37
+ MODE_ADVISORY = "advisory"
38
+ MODE_ENFORCING = "enforcing"
39
+
40
+ CURVE_LINEAR = "linear"
41
+ CURVE_BUSINESS_HOURS = "business_hours"
42
+
43
+ #: Business-hours curve: Monday to Friday, 09:00–17:00 UTC.
44
+ _BUSINESS_START_HOUR = 9
45
+ _BUSINESS_END_HOUR = 17
46
+ _BUSINESS_DAYS = 5
47
+ _BUSINESS_HOURS_PER_DAY = _BUSINESS_END_HOUR - _BUSINESS_START_HOUR
48
+ _BUSINESS_HOURS_PER_WEEK = _BUSINESS_DAYS * _BUSINESS_HOURS_PER_DAY
49
+
50
+
51
+ def week_start(now: float) -> float:
52
+ """The most recent Monday 00:00 UTC.
53
+
54
+ UTC and not the operator's zone, because the spend ledger is in UTC and a week
55
+ that starts at a different instant from the ledger it is measured against would
56
+ be wrong twice a year for anyone observing daylight saving.
57
+ """
58
+ moment = _dt.datetime.fromtimestamp(now, tz=_dt.timezone.utc)
59
+ midnight = moment.replace(hour=0, minute=0, second=0, microsecond=0)
60
+ return (midnight - _dt.timedelta(days=midnight.weekday())).timestamp()
61
+
62
+
63
+ def business_hours_fraction(now: float) -> float:
64
+ """How much of the working week has passed, as a fraction of its business hours.
65
+
66
+ A linear target is wrong for a team that works Monday to Friday: it expects a
67
+ fifth of the quota spent over a weekend when nobody is working, and then reads
68
+ as "behind pace" every Monday morning.
69
+ """
70
+ start = week_start(now)
71
+ elapsed_hours = 0.0
72
+ for day in range(_BUSINESS_DAYS):
73
+ day_start = start + day * 86400.0
74
+ open_at = day_start + _BUSINESS_START_HOUR * 3600.0
75
+ close_at = day_start + _BUSINESS_END_HOUR * 3600.0
76
+ if now >= close_at:
77
+ elapsed_hours += _BUSINESS_HOURS_PER_DAY
78
+ elif now > open_at:
79
+ elapsed_hours += (now - open_at) / 3600.0
80
+ return min(1.0, elapsed_hours / _BUSINESS_HOURS_PER_WEEK)
81
+
82
+
83
+ @dataclass
84
+ class PaceReading:
85
+ """One scope's answer to "should this be spent now"."""
86
+
87
+ scope: str
88
+ source: str
89
+ window: str
90
+ #: How far through the window we are, 0..1
91
+ elapsed_fraction: float
92
+ #: How much of the quota *should* be gone by now, under the chosen curve
93
+ target: float
94
+ #: How much of it actually is
95
+ utilization: float
96
+ #: utilization - target. Positive is ahead (will run dry early); negative is
97
+ #: behind (will strand quota).
98
+ pace: float
99
+ #: Where utilisation lands at window end if the current rate holds
100
+ projected_utilization: float
101
+ #: Fraction of the window's quota projected to go unused
102
+ stranded_fraction: float
103
+ #: Seconds until the quota is gone at the current rate, when that is before the
104
+ #: window resets
105
+ exhausted_in_seconds: Optional[float]
106
+ verdict: str
107
+
108
+ def to_dict(self) -> Dict[str, Any]:
109
+ return {
110
+ "scope": self.scope,
111
+ "source": self.source,
112
+ "window": self.window,
113
+ "elapsed_fraction": round(self.elapsed_fraction, 4),
114
+ "target": round(self.target, 4),
115
+ "utilization": round(self.utilization, 4),
116
+ "pace": round(self.pace, 4),
117
+ "projected_utilization": round(self.projected_utilization, 4),
118
+ "stranded_fraction": round(self.stranded_fraction, 4),
119
+ "exhausted_in_seconds": (
120
+ None if self.exhausted_in_seconds is None
121
+ else round(self.exhausted_in_seconds, 1)
122
+ ),
123
+ "verdict": self.verdict,
124
+ }
125
+
126
+
127
+ class PacingGovernor:
128
+ """Turns utilisation-against-elapsed-time into a pace, and optionally a delay."""
129
+
130
+ def __init__(
131
+ self,
132
+ enabled: bool = True,
133
+ mode: str = MODE_ADVISORY,
134
+ window: str = "7d",
135
+ curve: str = CURVE_LINEAR,
136
+ ahead_threshold: float = 0.10,
137
+ behind_threshold: float = 0.10,
138
+ max_batch_delay_seconds: float = 30.0,
139
+ weekly_budget_usd: Optional[float] = None,
140
+ ) -> None:
141
+ self.enabled = bool(enabled)
142
+ self.mode = mode
143
+ self.window = window if window in WINDOW_SECONDS else "7d"
144
+ self.curve = curve
145
+ self.ahead_threshold = max(0.0, float(ahead_threshold))
146
+ self.behind_threshold = max(0.0, float(behind_threshold))
147
+ self.max_batch_delay_seconds = max(0.0, float(max_batch_delay_seconds))
148
+ self.weekly_budget_usd = weekly_budget_usd
149
+
150
+ def reconfigure(self, **settings: Any) -> None:
151
+ for name, value in settings.items():
152
+ if hasattr(self, name):
153
+ setattr(self, name, value)
154
+ if self.window not in WINDOW_SECONDS:
155
+ self.window = "7d"
156
+
157
+ @property
158
+ def window_seconds(self) -> float:
159
+ return WINDOW_SECONDS[self.window]
160
+
161
+ @property
162
+ def enforcing(self) -> bool:
163
+ return self.enabled and self.mode == MODE_ENFORCING
164
+
165
+ # ---- readings ----------------------------------------------------------------
166
+
167
+ def target_for(self, elapsed_fraction: float, now: float) -> float:
168
+ if self.curve == CURVE_BUSINESS_HOURS and self.window == "7d":
169
+ return business_hours_fraction(now)
170
+ return elapsed_fraction
171
+
172
+ def from_unified(
173
+ self,
174
+ scope: str,
175
+ utilization: float,
176
+ seconds_to_reset: Optional[float],
177
+ now: float,
178
+ ) -> Optional[PaceReading]:
179
+ """A pace from a subscription's own rolling-window utilisation.
180
+
181
+ The window is treated as if it began `window_seconds` before it resets. It
182
+ is really a rolling window rather than a fixed one, so this is an
183
+ approximation — but it is an approximation over a measured utilisation
184
+ figure, which is a different thing from a guess.
185
+ """
186
+ if seconds_to_reset is None:
187
+ return None
188
+ total = self.window_seconds
189
+ elapsed = max(0.0, min(total, total - float(seconds_to_reset)))
190
+ return self._build(
191
+ scope, SOURCE_UNIFIED, elapsed / total, float(utilization),
192
+ float(seconds_to_reset), now,
193
+ )
194
+
195
+ def from_budget(
196
+ self, scope: str, spent_usd: float, now: float
197
+ ) -> Optional[PaceReading]:
198
+ """A pace from spend since Monday against a budget the operator stated."""
199
+ budget = self.weekly_budget_usd
200
+ if not budget or budget <= 0:
201
+ return None
202
+ started = week_start(now)
203
+ total = WINDOW_SECONDS["7d"]
204
+ elapsed = max(0.0, min(total, now - started))
205
+ return self._build(
206
+ scope, SOURCE_BUDGET, elapsed / total, spent_usd / float(budget),
207
+ total - elapsed, now,
208
+ )
209
+
210
+ def _build(
211
+ self,
212
+ scope: str,
213
+ source: str,
214
+ elapsed_fraction: float,
215
+ utilization: float,
216
+ seconds_remaining: float,
217
+ now: float,
218
+ ) -> PaceReading:
219
+ utilization = max(0.0, utilization)
220
+ target = self.target_for(elapsed_fraction, now)
221
+ pace = utilization - target
222
+
223
+ # Extrapolate at the rate established so far. Before anything has elapsed
224
+ # there is no rate to extrapolate, and claiming one would turn the first
225
+ # request of a window into a prediction that the quota runs out today.
226
+ if elapsed_fraction <= 0.0:
227
+ projected = utilization
228
+ exhausted_in: Optional[float] = None
229
+ else:
230
+ rate = utilization / elapsed_fraction
231
+ projected = rate
232
+ if rate > 1.0:
233
+ exhausted_at = 1.0 / rate
234
+ exhausted_in = max(
235
+ 0.0, (exhausted_at - elapsed_fraction) * self._total_for(source)
236
+ )
237
+ else:
238
+ exhausted_in = None
239
+
240
+ return PaceReading(
241
+ scope=scope,
242
+ source=source,
243
+ window=self.window if source == SOURCE_UNIFIED else "7d",
244
+ elapsed_fraction=elapsed_fraction,
245
+ target=target,
246
+ utilization=utilization,
247
+ pace=pace,
248
+ projected_utilization=projected,
249
+ stranded_fraction=max(0.0, 1.0 - projected),
250
+ exhausted_in_seconds=exhausted_in,
251
+ verdict=self._verdict(pace, projected, exhausted_in, seconds_remaining),
252
+ )
253
+
254
+ def _total_for(self, source: str) -> float:
255
+ return self.window_seconds if source == SOURCE_UNIFIED else WINDOW_SECONDS["7d"]
256
+
257
+ def _verdict(
258
+ self,
259
+ pace: float,
260
+ projected: float,
261
+ exhausted_in: Optional[float],
262
+ seconds_remaining: float,
263
+ ) -> str:
264
+ if pace > self.ahead_threshold:
265
+ if exhausted_in is not None:
266
+ return (
267
+ "ahead of pace — at this rate the quota is gone in "
268
+ f"{_duration(exhausted_in)}, with {_duration(seconds_remaining)} "
269
+ "of the window still to go"
270
+ )
271
+ return "ahead of pace, but not on course to run out"
272
+ if pace < -self.behind_threshold:
273
+ stranded = max(0.0, 1.0 - projected)
274
+ return (
275
+ f"behind pace — on course to leave {stranded:.0%} of this window's "
276
+ "quota unused"
277
+ )
278
+ return "on pace"
279
+
280
+ # ---- enforcement --------------------------------------------------------------
281
+
282
+ def delay_for(self, priority: int, batch_priority: int, pace: Optional[float]) -> float:
283
+ """How long to hold this request back, in seconds. Zero unless enforcing.
284
+
285
+ Interactive traffic is never delayed. There is no threshold at which making
286
+ someone's session slower to protect a budget is the right trade — an
287
+ operator who wants that wants a spend cap, which already exists and fails
288
+ honestly instead of quietly adding latency.
289
+ """
290
+ if not self.enforcing or pace is None:
291
+ return 0.0
292
+ if priority < batch_priority:
293
+ return 0.0
294
+ over = pace - self.ahead_threshold
295
+ if over <= 0:
296
+ return 0.0
297
+ # Scale into the delay budget over the same span again, so being twice the
298
+ # threshold ahead holds a batch request for the full allowance.
299
+ share = min(1.0, over / max(self.ahead_threshold, 0.01))
300
+ return round(self.max_batch_delay_seconds * share, 3)
301
+
302
+ def report(self, readings: List[PaceReading], now: float) -> Dict[str, Any]:
303
+ return {
304
+ "enabled": self.enabled,
305
+ "mode": self.mode,
306
+ "window": self.window,
307
+ "curve": self.curve,
308
+ "weekly_budget_usd": self.weekly_budget_usd,
309
+ "week_started_at": week_start(now),
310
+ "readings": [r.to_dict() for r in readings],
311
+ }
312
+
313
+
314
+ def _duration(seconds: float) -> str:
315
+ """Human-sized, because these numbers are read by a person in a console."""
316
+ if seconds < 3600:
317
+ return f"{seconds / 60:.0f}m"
318
+ if seconds < 86400:
319
+ return f"{seconds / 3600:.1f}h"
320
+ return f"{seconds / 86400:.1f}d"
@@ -0,0 +1,132 @@
1
+ """Admission control and the priority wait queue.
2
+
3
+ When nothing in the pool can serve a request, the honest options are to wait for a
4
+ known reset or to say so. Never to hang indefinitely: a caller told "four minutes"
5
+ can make a better decision than one left holding an open socket.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import heapq
12
+ import itertools
13
+ from dataclasses import dataclass, field
14
+ from typing import List, Optional
15
+
16
+ #: Lower number wins. Interactive traffic outranks batch.
17
+ PRIORITY_INTERACTIVE = 0
18
+ PRIORITY_BATCH = 10
19
+
20
+ PRIORITIES = {"interactive": PRIORITY_INTERACTIVE, "batch": PRIORITY_BATCH}
21
+
22
+ #: Clients name their priority; the queue orders by the number behind the name.
23
+ PRIORITY_HEADER = "x-tokenbiryani-priority"
24
+ MAX_WAIT_HEADER = "x-tokenbiryani-max-wait"
25
+
26
+
27
+ def parse_priority(value: Optional[str], default: int = PRIORITY_INTERACTIVE) -> int:
28
+ if not value:
29
+ return default
30
+ return PRIORITIES.get(value.strip().lower(), default)
31
+
32
+
33
+ def priority_name(level: int) -> str:
34
+ for name, value in PRIORITIES.items():
35
+ if value == level:
36
+ return name
37
+ return str(level)
38
+
39
+
40
+ class QueueFull(Exception):
41
+ """The wait queue is at capacity; shed load rather than grow latency silently."""
42
+
43
+
44
+ @dataclass(order=True)
45
+ class _Waiter:
46
+ priority: int
47
+ sequence: int
48
+ event: asyncio.Event = field(compare=False, default_factory=asyncio.Event)
49
+
50
+
51
+ class CapacityGate:
52
+ """A bounded, priority-ordered set of requests waiting for pool capacity.
53
+
54
+ Waiters are woken on two signals only: a lease being released, and a rate-limit
55
+ window resetting. Neither is a poll.
56
+ """
57
+
58
+ def __init__(self, max_size: int = 128, default_max_wait: float = 60.0) -> None:
59
+ self.max_size = max_size
60
+ self.default_max_wait = default_max_wait
61
+ self._heap: List[_Waiter] = []
62
+ self._counter = itertools.count()
63
+ self._lock = asyncio.Lock()
64
+
65
+ @property
66
+ def depth(self) -> int:
67
+ return len(self._heap)
68
+
69
+ async def wait(self, priority: int, timeout: float) -> bool:
70
+ """Join the queue. Returns True if woken, False if the deadline expired."""
71
+ if len(self._heap) >= self.max_size:
72
+ raise QueueFull(
73
+ f"wait queue is full ({len(self._heap)} waiting)"
74
+ )
75
+ waiter = _Waiter(priority=priority, sequence=next(self._counter))
76
+ async with self._lock:
77
+ heapq.heappush(self._heap, waiter)
78
+ try:
79
+ await asyncio.wait_for(waiter.event.wait(), timeout=max(0.0, timeout))
80
+ return True
81
+ except asyncio.TimeoutError:
82
+ return False
83
+ finally:
84
+ async with self._lock:
85
+ try:
86
+ self._heap.remove(waiter)
87
+ heapq.heapify(self._heap)
88
+ except ValueError:
89
+ pass
90
+
91
+ async def wake_one(self) -> None:
92
+ """Capacity freed somewhere: wake the highest-priority waiter."""
93
+ async with self._lock:
94
+ if self._heap:
95
+ self._heap[0].event.set()
96
+
97
+ async def wake_all(self) -> None:
98
+ """A reset window elapsed: let everyone re-evaluate."""
99
+ async with self._lock:
100
+ for waiter in self._heap:
101
+ waiter.event.set()
102
+
103
+ def snapshot(self) -> dict:
104
+ return {
105
+ "depth": len(self._heap),
106
+ "max_size": self.max_size,
107
+ "highest_priority": self._heap[0].priority if self._heap else None,
108
+ }
109
+
110
+
111
+ @dataclass
112
+ class AdmissionResult:
113
+ admitted: bool
114
+ reason: Optional[str] = None
115
+ retry_after: Optional[float] = None
116
+
117
+
118
+ def admission_check(
119
+ any_account_could_serve: bool, soonest_available: Optional[float]
120
+ ) -> AdmissionResult:
121
+ """Fail fast when no reset could ever make this request servable."""
122
+ if any_account_could_serve:
123
+ return AdmissionResult(True)
124
+ if soonest_available is None:
125
+ return AdmissionResult(
126
+ False,
127
+ reason=(
128
+ "no account in this pool can serve this request — it exceeds every "
129
+ "account's rate limit, or none supports the requested model"
130
+ ),
131
+ )
132
+ return AdmissionResult(False, reason="pool saturated", retry_after=soonest_available)