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