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/deferred.py ADDED
@@ -0,0 +1,41 @@
1
+ """Deferred request queue helpers.
2
+
3
+ When every key for a request is cooling, the gateway can't serve it now. Instead
4
+ of a 503, it persists the request, hands back an id + ETA, and a background worker
5
+ replays it through the normal keypool once capacity frees. The result is stored
6
+ and served on a later GET, then purged `deferred_ttl` seconds after completion.
7
+
8
+ This module holds the small pure helpers; the worker loop and the run path live
9
+ in server.py (they reuse its forwarding logic).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import secrets
15
+
16
+ DEFER_PREFIX = "defer_"
17
+
18
+
19
+ def new_request_id() -> str:
20
+ return DEFER_PREFIX + secrets.token_urlsafe(18)
21
+
22
+
23
+ def estimate_eta(ctx, providers: list[str]) -> float:
24
+ """Seconds until the soonest key for these providers is expected to be usable.
25
+
26
+ Accounts for both reasons a key can be unavailable: a provider cooldown (use
27
+ its remaining window) and a drained local bucket (use its refill time). Falls
28
+ back to the configured default cooldown when nothing reports a wait.
29
+ """
30
+ waits: list[float] = []
31
+ for pid in providers:
32
+ for cand in ctx.pool.candidates(pid):
33
+ if cand.ready:
34
+ return 0.0 # something can already go (rare at defer time)
35
+ if cand.cooling and cand.cooldown_remaining > 0:
36
+ waits.append(cand.cooldown_remaining)
37
+ else:
38
+ waits.append(cand.bucket.seconds_to_capacity())
39
+ if waits:
40
+ return max(0.0, min(waits))
41
+ return float(ctx.settings.default_cooldown)
synckey/keypool.py ADDED
@@ -0,0 +1,139 @@
1
+ """Key pool: proactive + tier-aware selection.
2
+
3
+ Selection order for a provider:
4
+ 1. LIVE keys whose bucket has capacity (best: no 429 risk)
5
+ 2. LIVE keys whose bucket is exhausted (might 429 but key itself is healthy)
6
+ 3. COOLING keys, soonest-free first (last resort)
7
+ DEAD keys are never returned.
8
+
9
+ This means the gateway stops sending to a key *before* it 429s, not after.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from dataclasses import dataclass
16
+
17
+ from .bucket import Bucket, BucketRegistry
18
+ from .crypto import SecretBox
19
+ from .db import Database, KeyRecord
20
+ from .state import Health, StateStore
21
+
22
+
23
+ @dataclass
24
+ class Candidate:
25
+ key_id: int
26
+ label: str
27
+ secret: str
28
+ provider: str
29
+ ready: bool # True if bucket has capacity right now
30
+ cooling: bool
31
+ cooldown_remaining: float
32
+ bucket: Bucket
33
+
34
+
35
+ def parse_retry_after(value: str | None) -> float | None:
36
+ if not value:
37
+ return None
38
+ try:
39
+ return float(value)
40
+ except ValueError:
41
+ return None
42
+
43
+
44
+ class KeyPool:
45
+ def __init__(
46
+ self,
47
+ db: Database,
48
+ box: SecretBox,
49
+ states: StateStore,
50
+ default_cooldown: float = 20.0,
51
+ ):
52
+ self.db = db
53
+ self.box = box
54
+ self.states = states
55
+ self.default_cooldown = default_cooldown
56
+ self._secrets: dict[int, str] = {}
57
+ self._buckets = BucketRegistry()
58
+ self._cursor: dict[str, int] = {}
59
+
60
+ def _secret(self, rec: KeyRecord) -> str:
61
+ s = self._secrets.get(rec.id)
62
+ if s is None:
63
+ s = self.box.open(rec.secret)
64
+ self._secrets[rec.id] = s
65
+ return s
66
+
67
+ def candidates(self, provider: str, estimated_tokens: int = 0) -> list[Candidate]:
68
+ records = self.db.list_keys(provider=provider, enabled_only=True)
69
+ if not records:
70
+ return []
71
+
72
+ weighted: list[KeyRecord] = []
73
+ for r in records:
74
+ weighted.extend([r] * max(1, r.weight))
75
+
76
+ start = self._cursor.get(provider, 0) % len(weighted)
77
+ self._cursor[provider] = start + 1
78
+ rotated = weighted[start:] + weighted[:start]
79
+
80
+ now = time.time()
81
+ ready: list[Candidate] = []
82
+ throttled: list[Candidate] = []
83
+ cooling: list[Candidate] = []
84
+ seen: set[int] = set()
85
+
86
+ for r in rotated:
87
+ if r.id in seen:
88
+ continue
89
+ seen.add(r.id)
90
+
91
+ st = self.states.get(r.id)
92
+ if st.health == Health.DEAD:
93
+ continue
94
+
95
+ bucket = self._buckets.get(r.id, rpm_cap=r.rpm_limit, tpm_cap=r.tpm_limit)
96
+ remaining = max(0.0, st.cooldown_until - now) if st.health == Health.COOLING else 0.0
97
+ is_cooling = remaining > 0
98
+ can_send = bucket.can_send(estimated_tokens)
99
+
100
+ cand = Candidate(
101
+ key_id=r.id,
102
+ label=r.label,
103
+ secret=self._secret(r),
104
+ provider=provider,
105
+ ready=can_send and not is_cooling,
106
+ cooling=is_cooling,
107
+ cooldown_remaining=remaining,
108
+ bucket=bucket,
109
+ )
110
+ if is_cooling:
111
+ cooling.append(cand)
112
+ elif can_send:
113
+ ready.append(cand)
114
+ else:
115
+ throttled.append(cand)
116
+
117
+ cooling.sort(key=lambda c: c.cooldown_remaining)
118
+ return ready + throttled + cooling
119
+
120
+ def on_success(self, key_id: int) -> None:
121
+ self.states.set_live(key_id)
122
+ self._buckets.get(key_id).on_success()
123
+
124
+ def on_rate_limit(self, key_id: int, retry_after: float | None, rpm_at_429: float | None = None) -> None:
125
+ cooldown = retry_after if retry_after and retry_after > 0 else self.default_cooldown
126
+ self.states.set_cooling(key_id, time.time() + cooldown, rpm_observed=rpm_at_429)
127
+ self._buckets.get(key_id).on_rate_limit(retry_after, rpm_at_429)
128
+
129
+ def on_dead(self, key_id: int, reason: str) -> None:
130
+ self.states.set_dead(key_id, reason)
131
+
132
+ def on_failure(self, key_id: int) -> None:
133
+ st = self.states.get(key_id)
134
+ fails = (1 if st.health == Health.LIVE else 2)
135
+ delay = min(self.default_cooldown, 2.0 * fails)
136
+ self.states.set_cooling(key_id, time.time() + delay)
137
+
138
+ def consume(self, key_id: int, estimated_tokens: int = 0) -> None:
139
+ self._buckets.get(key_id).consume(estimated_tokens)
synckey/prefs.py ADDED
@@ -0,0 +1,82 @@
1
+ """User-managed routing preferences.
2
+
3
+ These are the knobs a user sets interactively (via `synckey setup`, `config`,
4
+ `tier`, or the `key add` flow) rather than hand-editing a file:
5
+
6
+ global_default_model model used when a request names neither model nor provider
7
+ provider_defaults {provider: model} used when a request names only a provider
8
+ aliases {name: target} target is a model id or a tier name
9
+ tier_overrides {model: tier} manual tier assignment
10
+
11
+ They live in DB meta (JSON) so they survive restarts and are editable
12
+ programmatically. config.toml stays the home for *static* infra (host/port,
13
+ custom providers, price overrides) that you version-control; these are the
14
+ *dynamic* choices you tune as you go. `synckey config` shows both in one place.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .db import Database
20
+
21
+ GLOBAL_DEFAULT = "global_default_model"
22
+ PROVIDER_DEFAULTS = "provider_defaults"
23
+ ALIASES = "aliases"
24
+ TIER_OVERRIDES = "tier_overrides_db"
25
+
26
+
27
+ class Prefs:
28
+ def __init__(self, db: Database):
29
+ self.db = db
30
+
31
+ # global default
32
+ def global_default(self) -> str | None:
33
+ return self.db.get_meta(GLOBAL_DEFAULT)
34
+
35
+ def set_global_default(self, model: str) -> None:
36
+ self.db.set_meta(GLOBAL_DEFAULT, model)
37
+
38
+ # per-provider defaults
39
+ def provider_defaults(self) -> dict[str, str]:
40
+ return self.db.get_json(PROVIDER_DEFAULTS, {}) or {}
41
+
42
+ def provider_default(self, provider: str) -> str | None:
43
+ return self.provider_defaults().get(provider.lower())
44
+
45
+ def set_provider_default(self, provider: str, model: str) -> None:
46
+ d = self.provider_defaults()
47
+ d[provider.lower()] = model
48
+ self.db.set_json(PROVIDER_DEFAULTS, d)
49
+
50
+ # aliases
51
+ def aliases(self) -> dict[str, str]:
52
+ return self.db.get_json(ALIASES, {}) or {}
53
+
54
+ def set_alias(self, name: str, target: str) -> None:
55
+ a = self.aliases()
56
+ a[name.lower()] = target
57
+ self.db.set_json(ALIASES, a)
58
+
59
+ def remove_alias(self, name: str) -> bool:
60
+ a = self.aliases()
61
+ if name.lower() in a:
62
+ del a[name.lower()]
63
+ self.db.set_json(ALIASES, a)
64
+ return True
65
+ return False
66
+
67
+ # tier overrides
68
+ def tier_overrides(self) -> dict[str, str]:
69
+ return self.db.get_json(TIER_OVERRIDES, {}) or {}
70
+
71
+ def set_tier_override(self, model: str, tier: str) -> None:
72
+ t = self.tier_overrides()
73
+ t[model.lower()] = tier
74
+ self.db.set_json(TIER_OVERRIDES, t)
75
+
76
+ def remove_tier_override(self, model: str) -> bool:
77
+ t = self.tier_overrides()
78
+ if model.lower() in t:
79
+ del t[model.lower()]
80
+ self.db.set_json(TIER_OVERRIDES, t)
81
+ return True
82
+ return False
synckey/providers.py ADDED
@@ -0,0 +1,189 @@
1
+ """Built-in provider registry.
2
+
3
+ Every provider exposes an OpenAI-compatible REST surface, so one adapter (base
4
+ URL plus a bearer key) reaches all of them. Patterns are compiled once at
5
+ construction and only used as a fallback when prefix and live-index routing miss.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from typing import Pattern
13
+
14
+
15
+ @dataclass
16
+ class Provider:
17
+ id: str
18
+ name: str
19
+ base_url: str
20
+ models_path: str = "/models"
21
+ env: tuple[str, ...] = ()
22
+ patterns: tuple[str, ...] = ()
23
+ auth: str = "bearer" # "bearer" | "x-api-key" | "query:<param>"
24
+ signup: str = ""
25
+ notes: str = ""
26
+ _rx: list[Pattern[str]] = field(default_factory=list, init=False, repr=False, compare=False)
27
+
28
+ def __post_init__(self) -> None:
29
+ self._rx = [re.compile(p, re.IGNORECASE) for p in self.patterns]
30
+
31
+ def matches(self, model: str) -> bool:
32
+ return any(rx.search(model) for rx in self._rx)
33
+
34
+ def auth_headers(self, secret: str) -> dict[str, str]:
35
+ if self.auth == "bearer":
36
+ return {"Authorization": f"Bearer {secret}"}
37
+ if self.auth == "x-api-key":
38
+ return {"x-api-key": secret}
39
+ return {}
40
+
41
+ def auth_params(self, secret: str) -> dict[str, str]:
42
+ if self.auth.startswith("query:"):
43
+ return {self.auth.split(":", 1)[1]: secret}
44
+ return {}
45
+
46
+
47
+ # Patterns only claim models a provider unambiguously owns. Shared open-weight
48
+ # names (llama, qwen, deepseek) are resolved at runtime via the live index.
49
+ BUILTIN: dict[str, Provider] = {
50
+ p.id: p
51
+ for p in (
52
+ Provider(
53
+ id="gemini",
54
+ name="Google Gemini",
55
+ base_url="https://generativelanguage.googleapis.com/v1beta/openai",
56
+ env=("GEMINI_API_KEY", "GOOGLE_API_KEY"),
57
+ patterns=(r"^gemini[-/]", r"^gemma[-/]?", r"^learnlm", r"^text-embedding-00"),
58
+ signup="https://aistudio.google.com/apikey",
59
+ ),
60
+ Provider(
61
+ id="groq",
62
+ name="Groq",
63
+ base_url="https://api.groq.com/openai/v1",
64
+ env=("GROQ_API_KEY",),
65
+ patterns=(r"versatile$", r"instant$", r"^whisper-large", r"^groq/", r"^gpt-oss", r"^openai/gpt-oss"),
66
+ signup="https://console.groq.com/keys",
67
+ ),
68
+ Provider(
69
+ id="cerebras",
70
+ name="Cerebras",
71
+ base_url="https://api.cerebras.ai/v1",
72
+ env=("CEREBRAS_API_KEY",),
73
+ patterns=(r"^llama3\.1-", r"^llama-3\.3-70b$", r"^cerebras/"),
74
+ signup="https://cloud.cerebras.ai",
75
+ ),
76
+ Provider(
77
+ id="nvidia",
78
+ name="NVIDIA NIM",
79
+ base_url="https://integrate.api.nvidia.com/v1",
80
+ env=("NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"),
81
+ patterns=(r"^nvidia/", r"nemotron"),
82
+ signup="https://build.nvidia.com",
83
+ notes="Models are org-namespaced, e.g. meta/llama-3.3-70b-instruct.",
84
+ ),
85
+ Provider(
86
+ id="github",
87
+ name="GitHub Models",
88
+ base_url="https://models.github.ai/inference",
89
+ models_path="/catalog/models",
90
+ env=("GITHUB_TOKEN", "GITHUB_MODELS_TOKEN"),
91
+ patterns=(r"^github/",),
92
+ signup="https://github.com/settings/personal-access-tokens",
93
+ notes="Use a fine-grained PAT with the models:read permission.",
94
+ ),
95
+ Provider(
96
+ id="cohere",
97
+ name="Cohere",
98
+ base_url="https://api.cohere.ai/compatibility/v1",
99
+ env=("COHERE_API_KEY", "CO_API_KEY"),
100
+ patterns=(r"^command", r"^embed-", r"^rerank", r"^cohere/"),
101
+ signup="https://dashboard.cohere.com/api-keys",
102
+ ),
103
+ Provider(
104
+ id="openrouter",
105
+ name="OpenRouter",
106
+ base_url="https://openrouter.ai/api/v1",
107
+ env=("OPENROUTER_API_KEY",),
108
+ patterns=(r"^openrouter/",),
109
+ signup="https://openrouter.ai/keys",
110
+ ),
111
+ Provider(
112
+ id="together",
113
+ name="Together AI",
114
+ base_url="https://api.together.xyz/v1",
115
+ env=("TOGETHER_API_KEY",),
116
+ patterns=(r"^together/",),
117
+ signup="https://api.together.ai/settings/api-keys",
118
+ ),
119
+ Provider(
120
+ id="mistral",
121
+ name="Mistral AI",
122
+ base_url="https://api.mistral.ai/v1",
123
+ env=("MISTRAL_API_KEY",),
124
+ patterns=(r"^mistral", r"^codestral", r"^ministral", r"^pixtral", r"^magistral"),
125
+ signup="https://console.mistral.ai/api-keys",
126
+ ),
127
+ Provider(
128
+ id="deepseek",
129
+ name="DeepSeek",
130
+ base_url="https://api.deepseek.com/v1",
131
+ env=("DEEPSEEK_API_KEY",),
132
+ patterns=(r"^deepseek-chat$", r"^deepseek-reasoner$"),
133
+ signup="https://platform.deepseek.com/api_keys",
134
+ ),
135
+ Provider(
136
+ id="xai",
137
+ name="xAI Grok",
138
+ base_url="https://api.x.ai/v1",
139
+ env=("XAI_API_KEY",),
140
+ patterns=(r"^grok",),
141
+ signup="https://console.x.ai",
142
+ ),
143
+ Provider(
144
+ id="sambanova",
145
+ name="SambaNova",
146
+ base_url="https://api.sambanova.ai/v1",
147
+ env=("SAMBANOVA_API_KEY",),
148
+ patterns=(r"^sambanova/",),
149
+ signup="https://cloud.sambanova.ai/apis",
150
+ ),
151
+ Provider(
152
+ id="openai",
153
+ name="OpenAI",
154
+ base_url="https://api.openai.com/v1",
155
+ env=("OPENAI_API_KEY",),
156
+ patterns=(r"^gpt-", r"^o[0-9]", r"^chatgpt", r"^dall-e", r"^text-embedding-3"),
157
+ signup="https://platform.openai.com/api-keys",
158
+ ),
159
+ Provider(
160
+ id="ollama",
161
+ name="Ollama (local)",
162
+ base_url="http://localhost:11434/v1",
163
+ env=("OLLAMA_HOST",),
164
+ patterns=(r"^ollama/",),
165
+ signup="https://ollama.com",
166
+ notes="No key required for a default local install.",
167
+ ),
168
+ )
169
+ }
170
+
171
+
172
+ def split_provider_prefix(model: str, known_ids: set[str]) -> tuple[str | None, str]:
173
+ """Split a provider/model prefix when the head is a registered provider.
174
+
175
+ Org-namespaced names like meta/llama-3.3-70b are left intact because meta is
176
+ not a registered provider.
177
+ """
178
+ if "/" in model:
179
+ head, tail = model.split("/", 1)
180
+ if head.lower() in known_ids:
181
+ return head.lower(), tail
182
+ return None, model
183
+
184
+
185
+ def match_by_pattern(model: str, providers: dict[str, Provider]) -> str | None:
186
+ for pid, prov in providers.items():
187
+ if prov.matches(model):
188
+ return pid
189
+ return None