synckey 0.1.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.
- synckey/__init__.py +3 -0
- synckey/__main__.py +4 -0
- synckey/bucket.py +140 -0
- synckey/cli.py +1481 -0
- synckey/config.py +150 -0
- synckey/context.py +75 -0
- synckey/crypto.py +41 -0
- synckey/db.py +426 -0
- synckey/deferred.py +41 -0
- synckey/keypool.py +139 -0
- synckey/prefs.py +82 -0
- synckey/providers.py +189 -0
- synckey/router.py +291 -0
- synckey/server.py +672 -0
- synckey/state.py +152 -0
- synckey/tiers.py +157 -0
- synckey/usage.py +99 -0
- synckey-0.1.0.dist-info/METADATA +799 -0
- synckey-0.1.0.dist-info/RECORD +21 -0
- synckey-0.1.0.dist-info/WHEEL +4 -0
- synckey-0.1.0.dist-info/entry_points.txt +2 -0
synckey/__init__.py
ADDED
synckey/__main__.py
ADDED
synckey/bucket.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Per-key proactive rate buckets.
|
|
2
|
+
|
|
3
|
+
The goal: never send a request that we predict will 429.
|
|
4
|
+
|
|
5
|
+
Each key gets a token bucket sized to its observed RPM (and optionally TPM).
|
|
6
|
+
Before picking a key, we check can_send() -- if it returns False, the key is
|
|
7
|
+
proactively skipped. This turns the reactive cycle of "send -> get 429 -> wait"
|
|
8
|
+
into "check bucket -> skip -> try next key" with zero wasted upstream calls.
|
|
9
|
+
|
|
10
|
+
Limit discovery is adaptive:
|
|
11
|
+
- Keys start with unlimited capacity (no limit known yet).
|
|
12
|
+
- On first 429, the current emission rate is recorded as the ceiling and the
|
|
13
|
+
bucket is tightened below it.
|
|
14
|
+
- Retry-After tells us how long the provider is cooling us, which implies the
|
|
15
|
+
reset window; we use that to infer a safer RPM.
|
|
16
|
+
- After N consecutive successes the cap is relaxed by 10% (up to the observed
|
|
17
|
+
max).
|
|
18
|
+
|
|
19
|
+
Users can also declare RPM/TPM per key in config to skip the discovery phase.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Bucket:
|
|
30
|
+
# Requests per minute limit. None = no limit known yet.
|
|
31
|
+
rpm_cap: float | None = None
|
|
32
|
+
# Tokens per minute limit. None = no limit known yet.
|
|
33
|
+
tpm_cap: float | None = None
|
|
34
|
+
# Current available request tokens (refills continuously).
|
|
35
|
+
_req_tokens: float = field(default=0.0, init=False, repr=False)
|
|
36
|
+
# Current available token-count tokens.
|
|
37
|
+
_tok_tokens: float = field(default=0.0, init=False, repr=False)
|
|
38
|
+
_last_refill: float = field(default_factory=time.monotonic, init=False, repr=False)
|
|
39
|
+
# Rolling request count in the last 60s for emission-rate tracking.
|
|
40
|
+
_window: list[float] = field(default_factory=list, init=False, repr=False)
|
|
41
|
+
_consecutive_ok: int = field(default=0, init=False, repr=False)
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
self._req_tokens = float(self.rpm_cap or 1_000_000)
|
|
45
|
+
self._tok_tokens = float(self.tpm_cap or 1_000_000_000)
|
|
46
|
+
self._last_refill = time.monotonic()
|
|
47
|
+
|
|
48
|
+
def _refill(self) -> None:
|
|
49
|
+
now = time.monotonic()
|
|
50
|
+
elapsed = now - self._last_refill
|
|
51
|
+
if elapsed <= 0:
|
|
52
|
+
return
|
|
53
|
+
self._last_refill = now
|
|
54
|
+
if self.rpm_cap is not None:
|
|
55
|
+
self._req_tokens = min(self.rpm_cap, self._req_tokens + elapsed * self.rpm_cap / 60.0)
|
|
56
|
+
if self.tpm_cap is not None:
|
|
57
|
+
self._tok_tokens = min(self.tpm_cap, self._tok_tokens + elapsed * self.tpm_cap / 60.0)
|
|
58
|
+
|
|
59
|
+
def emission_rpm(self) -> float:
|
|
60
|
+
"""Estimate actual current emission rate (requests per minute)."""
|
|
61
|
+
now = time.time()
|
|
62
|
+
cutoff = now - 60.0
|
|
63
|
+
self._window = [t for t in self._window if t > cutoff]
|
|
64
|
+
return len(self._window) * (60.0 / max(1.0, now - (self._window[0] if self._window else now)))
|
|
65
|
+
|
|
66
|
+
def can_send(self, estimated_tokens: int = 0) -> bool:
|
|
67
|
+
self._refill()
|
|
68
|
+
if self.rpm_cap is not None and self._req_tokens < 1.0:
|
|
69
|
+
return False
|
|
70
|
+
if self.tpm_cap is not None and self._tok_tokens < estimated_tokens:
|
|
71
|
+
return False
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
def consume(self, estimated_tokens: int = 0) -> None:
|
|
75
|
+
self._window.append(time.time())
|
|
76
|
+
if len(self._window) > 500:
|
|
77
|
+
self._window = self._window[-500:]
|
|
78
|
+
self._req_tokens = max(0.0, self._req_tokens - 1.0)
|
|
79
|
+
if self.tpm_cap is not None and estimated_tokens:
|
|
80
|
+
self._tok_tokens = max(0.0, self._tok_tokens - estimated_tokens)
|
|
81
|
+
|
|
82
|
+
def on_success(self) -> None:
|
|
83
|
+
self._consecutive_ok += 1
|
|
84
|
+
# After 20 consecutive successes, relax the cap by 10%.
|
|
85
|
+
if self._consecutive_ok >= 20 and self.rpm_cap is not None:
|
|
86
|
+
self.rpm_cap = min(self.rpm_cap * 1.1, self.rpm_cap * 2)
|
|
87
|
+
self._consecutive_ok = 0
|
|
88
|
+
|
|
89
|
+
def on_rate_limit(self, retry_after: float | None, rpm_observed: float | None = None) -> None:
|
|
90
|
+
"""Tighten the cap based on a 429 response."""
|
|
91
|
+
self._consecutive_ok = 0
|
|
92
|
+
if rpm_observed and rpm_observed > 0:
|
|
93
|
+
# Set cap to 80% of observed emission rate at time of 429.
|
|
94
|
+
new_cap = rpm_observed * 0.80
|
|
95
|
+
self.rpm_cap = new_cap if self.rpm_cap is None else min(self.rpm_cap, new_cap)
|
|
96
|
+
elif self.rpm_cap is None:
|
|
97
|
+
# We had no cap; seed from Retry-After window.
|
|
98
|
+
if retry_after and retry_after > 0:
|
|
99
|
+
# If the window is e.g. 20s, the provider is doing 60/20 = 3 rpm.
|
|
100
|
+
# Be conservative and set it lower.
|
|
101
|
+
self.rpm_cap = max(1.0, (60.0 / retry_after) * 0.7)
|
|
102
|
+
else:
|
|
103
|
+
self.rpm_cap = 30.0 # conservative default
|
|
104
|
+
else:
|
|
105
|
+
self.rpm_cap = max(1.0, self.rpm_cap * 0.75)
|
|
106
|
+
# Drain the bucket so we don't immediately send more.
|
|
107
|
+
self._req_tokens = 0.0
|
|
108
|
+
|
|
109
|
+
def seconds_to_capacity(self) -> float:
|
|
110
|
+
"""Estimate seconds until at least one request token refills (0 if ready now)."""
|
|
111
|
+
self._refill()
|
|
112
|
+
if self.rpm_cap is None or self._req_tokens >= 1.0:
|
|
113
|
+
return 0.0
|
|
114
|
+
rate = self.rpm_cap / 60.0 # tokens per second
|
|
115
|
+
if rate <= 0:
|
|
116
|
+
return 0.0
|
|
117
|
+
return (1.0 - self._req_tokens) / rate
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class BucketRegistry:
|
|
121
|
+
"""Creates and caches one Bucket per key_id."""
|
|
122
|
+
|
|
123
|
+
def __init__(self) -> None:
|
|
124
|
+
self._buckets: dict[int, Bucket] = {}
|
|
125
|
+
|
|
126
|
+
def get(self, key_id: int, rpm_cap: float | None = None, tpm_cap: float | None = None) -> Bucket:
|
|
127
|
+
if key_id not in self._buckets:
|
|
128
|
+
self._buckets[key_id] = Bucket(rpm_cap=rpm_cap, tpm_cap=tpm_cap)
|
|
129
|
+
b = self._buckets[key_id]
|
|
130
|
+
# If the caller now knows the caps (e.g. loaded from config), apply them.
|
|
131
|
+
if rpm_cap is not None and b.rpm_cap is None:
|
|
132
|
+
b.rpm_cap = rpm_cap
|
|
133
|
+
b._req_tokens = rpm_cap
|
|
134
|
+
if tpm_cap is not None and b.tpm_cap is None:
|
|
135
|
+
b.tpm_cap = tpm_cap
|
|
136
|
+
b._tok_tokens = tpm_cap
|
|
137
|
+
return b
|
|
138
|
+
|
|
139
|
+
def drop(self, key_id: int) -> None:
|
|
140
|
+
self._buckets.pop(key_id, None)
|