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 ADDED
@@ -0,0 +1,3 @@
1
+ """synckey: merge every AI provider key behind one unified API key."""
2
+
3
+ __version__ = "0.1.0"
synckey/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
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)