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/router.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Routing: model name -> ordered provider list, with tier-floored fallback.
|
|
2
|
+
|
|
3
|
+
Resolution order:
|
|
4
|
+
1. Explicit provider/model prefix (groq/llama-3.3-70b-versatile)
|
|
5
|
+
2. Live model index (what your keys actually advertise, ordered by priority)
|
|
6
|
+
3. Static name patterns (gemini-2.0-flash -> gemini)
|
|
7
|
+
|
|
8
|
+
Tier floor: when all providers for the requested model are exhausted, we look
|
|
9
|
+
for same-tier alternatives in the index (never dropping below the floor).
|
|
10
|
+
Opus -> GPT-5 is fine. Opus -> gpt-oss is not.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from .config import Settings
|
|
22
|
+
from .db import Database
|
|
23
|
+
from .prefs import Prefs
|
|
24
|
+
from .providers import Provider, match_by_pattern, split_provider_prefix
|
|
25
|
+
from .tiers import TIER_BY_NAME, model_tier
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Resolution:
|
|
29
|
+
__slots__ = ("bare_model", "providers", "how", "tier", "floor")
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
bare_model: str,
|
|
34
|
+
providers: list[str],
|
|
35
|
+
how: str,
|
|
36
|
+
tier: int | None = None,
|
|
37
|
+
floor: int | None = None,
|
|
38
|
+
):
|
|
39
|
+
self.bare_model = bare_model
|
|
40
|
+
self.providers = providers
|
|
41
|
+
self.how = how
|
|
42
|
+
self.tier = tier # detected tier of the requested model
|
|
43
|
+
self.floor = floor # fallback is not allowed below this tier
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def order_by_priority(providers: list[str], priority: list[str]) -> list[str]:
|
|
47
|
+
rank = {pid: i for i, pid in enumerate(priority)}
|
|
48
|
+
return sorted(providers, key=lambda p: rank.get(p, len(priority)))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_model_ids(payload: dict) -> list[str]:
|
|
52
|
+
if not isinstance(payload, dict):
|
|
53
|
+
return []
|
|
54
|
+
if isinstance(payload.get("data"), list):
|
|
55
|
+
return [
|
|
56
|
+
str(item.get("id") or item.get("name"))
|
|
57
|
+
for item in payload["data"]
|
|
58
|
+
if isinstance(item, dict) and (item.get("id") or item.get("name"))
|
|
59
|
+
]
|
|
60
|
+
if isinstance(payload.get("models"), list):
|
|
61
|
+
return [
|
|
62
|
+
str(m.get("id") or m.get("name"))
|
|
63
|
+
for m in payload["models"]
|
|
64
|
+
if isinstance(m, dict) and (m.get("id") or m.get("name"))
|
|
65
|
+
]
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Router:
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
db: Database,
|
|
73
|
+
providers: dict[str, Provider],
|
|
74
|
+
settings: Settings,
|
|
75
|
+
prefs: Prefs | None = None,
|
|
76
|
+
):
|
|
77
|
+
self.db = db
|
|
78
|
+
self.providers = providers
|
|
79
|
+
self.settings = settings
|
|
80
|
+
self.prefs = prefs or Prefs(db)
|
|
81
|
+
self._known_ids = set(providers)
|
|
82
|
+
self._index: dict[str, set[str]] = {}
|
|
83
|
+
self._tier_index: dict[int, list[str]] = {} # tier -> [model_ids in index]
|
|
84
|
+
self._index_built_at: float = 0.0
|
|
85
|
+
# User defaults/aliases are read once at startup (hot-path: no per-request
|
|
86
|
+
# DB read). CLI changes apply on the next `serve`; call reload_prefs() to
|
|
87
|
+
# pick them up in-process (tests, the dashboard).
|
|
88
|
+
self._aliases: dict[str, str] = {}
|
|
89
|
+
self._global_default: str | None = None
|
|
90
|
+
self._provider_defaults: dict[str, str] = {}
|
|
91
|
+
self.reload_prefs()
|
|
92
|
+
self._load_cached_index()
|
|
93
|
+
|
|
94
|
+
def reload_prefs(self) -> None:
|
|
95
|
+
self._aliases = {k.lower(): v for k, v in self.prefs.aliases().items()}
|
|
96
|
+
self._global_default = self.prefs.global_default() or None
|
|
97
|
+
self._provider_defaults = {
|
|
98
|
+
k.lower(): v for k, v in self.prefs.provider_defaults().items()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def _load_cached_index(self) -> None:
|
|
102
|
+
raw = self.db.get_meta("model_index")
|
|
103
|
+
if not raw:
|
|
104
|
+
return
|
|
105
|
+
try:
|
|
106
|
+
data = json.loads(raw)
|
|
107
|
+
self._index = {m: set(ps) for m, ps in data.get("index", {}).items()}
|
|
108
|
+
self._index_built_at = data.get("built_at", 0.0)
|
|
109
|
+
self._rebuild_tier_index()
|
|
110
|
+
except (ValueError, KeyError):
|
|
111
|
+
self._index = {}
|
|
112
|
+
|
|
113
|
+
def _save_cached_index(self) -> None:
|
|
114
|
+
self.db.set_meta(
|
|
115
|
+
"model_index",
|
|
116
|
+
json.dumps(
|
|
117
|
+
{
|
|
118
|
+
"built_at": self._index_built_at,
|
|
119
|
+
"index": {m: sorted(ps) for m, ps in self._index.items()},
|
|
120
|
+
}
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _rebuild_tier_index(self) -> None:
|
|
125
|
+
self._tier_index = {}
|
|
126
|
+
for model in self._index:
|
|
127
|
+
t = model_tier(model)
|
|
128
|
+
if t is not None:
|
|
129
|
+
self._tier_index.setdefault(t, []).append(model)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def index(self) -> dict[str, set[str]]:
|
|
133
|
+
return self._index
|
|
134
|
+
|
|
135
|
+
def index_age(self) -> float:
|
|
136
|
+
return time.time() - self._index_built_at if self._index_built_at else float("inf")
|
|
137
|
+
|
|
138
|
+
async def refresh_index(self, secret_for) -> dict[str, list[str]]:
|
|
139
|
+
configured = self.db.providers_with_keys()
|
|
140
|
+
index: dict[str, set[str]] = {}
|
|
141
|
+
discovered: dict[str, list[str]] = {}
|
|
142
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
143
|
+
results = await asyncio.gather(
|
|
144
|
+
*(self._fetch_models(client, pid, secret_for(pid)) for pid in configured),
|
|
145
|
+
return_exceptions=True,
|
|
146
|
+
)
|
|
147
|
+
for pid, result in zip(configured, results):
|
|
148
|
+
models = [] if isinstance(result, Exception) else result
|
|
149
|
+
discovered[pid] = models
|
|
150
|
+
for m in models:
|
|
151
|
+
index.setdefault(m, set()).add(pid)
|
|
152
|
+
self._index = index
|
|
153
|
+
self._index_built_at = time.time()
|
|
154
|
+
self._rebuild_tier_index()
|
|
155
|
+
self._save_cached_index()
|
|
156
|
+
return discovered
|
|
157
|
+
|
|
158
|
+
async def _fetch_models(self, client: httpx.AsyncClient, pid: str, secret: str | None) -> list[str]:
|
|
159
|
+
prov = self.providers[pid]
|
|
160
|
+
if not secret and prov.auth != "none":
|
|
161
|
+
return []
|
|
162
|
+
url = prov.base_url.rstrip("/") + prov.models_path
|
|
163
|
+
resp = await client.get(
|
|
164
|
+
url, headers=prov.auth_headers(secret or ""), params=prov.auth_params(secret or "")
|
|
165
|
+
)
|
|
166
|
+
resp.raise_for_status()
|
|
167
|
+
return parse_model_ids(resp.json())
|
|
168
|
+
|
|
169
|
+
def resolve(self, model: str, floor: int | None = None) -> Resolution:
|
|
170
|
+
tier = model_tier(model)
|
|
171
|
+
effective_floor = floor if floor is not None else tier
|
|
172
|
+
|
|
173
|
+
# Index-first: if the full model string is in the live index, use it.
|
|
174
|
+
# This prevents org-namespaced names like "openai/gpt-oss-120b" from
|
|
175
|
+
# being mis-routed by prefix splitting (e.g. to OpenAI when it lives
|
|
176
|
+
# on Groq).
|
|
177
|
+
providers = self._index.get(model)
|
|
178
|
+
if providers:
|
|
179
|
+
ordered = order_by_priority(sorted(providers), self.settings.provider_priority)
|
|
180
|
+
return Resolution(model, ordered, "index", tier=tier, floor=effective_floor)
|
|
181
|
+
|
|
182
|
+
# Explicit prefix (groq/llama-3.3-70b-versatile).
|
|
183
|
+
pid, bare = split_provider_prefix(model, self._known_ids)
|
|
184
|
+
if pid:
|
|
185
|
+
tier = model_tier(bare)
|
|
186
|
+
effective_floor = floor if floor is not None else tier
|
|
187
|
+
return Resolution(bare, [pid], "prefix", tier=tier, floor=effective_floor)
|
|
188
|
+
|
|
189
|
+
# Static name patterns.
|
|
190
|
+
guess = match_by_pattern(model, self.providers)
|
|
191
|
+
if guess:
|
|
192
|
+
return Resolution(model, [guess], "pattern", tier=tier, floor=effective_floor)
|
|
193
|
+
|
|
194
|
+
return Resolution(model, [], "unknown", tier=tier, floor=effective_floor)
|
|
195
|
+
|
|
196
|
+
def _models_at_tier(self, tier: int) -> list[str]:
|
|
197
|
+
"""Models in the live index at `tier`, ordered by their best provider's priority."""
|
|
198
|
+
prio = self.settings.provider_priority
|
|
199
|
+
|
|
200
|
+
def best_rank(model: str) -> int:
|
|
201
|
+
provs = self._index.get(model, set())
|
|
202
|
+
if not provs:
|
|
203
|
+
return len(prio) + 1
|
|
204
|
+
return min(prio.index(p) if p in prio else len(prio) for p in provs)
|
|
205
|
+
|
|
206
|
+
return sorted(self._tier_index.get(tier, []), key=best_rank)
|
|
207
|
+
|
|
208
|
+
def materialize(self, value: str) -> str | None:
|
|
209
|
+
"""Expand an alias or tier name into a concrete model id.
|
|
210
|
+
|
|
211
|
+
- `value` may be an alias ("fast"), a tier ("mid" or "tier:mid"), or a
|
|
212
|
+
plain model id. Alias chains are followed (with a cycle guard).
|
|
213
|
+
- A tier resolves to the best live model at that tier, or None if the
|
|
214
|
+
index has none.
|
|
215
|
+
- A plain model id passes through unchanged.
|
|
216
|
+
"""
|
|
217
|
+
v = value
|
|
218
|
+
seen: set[str] = set()
|
|
219
|
+
while v and v.lower() in self._aliases and v.lower() not in seen:
|
|
220
|
+
seen.add(v.lower())
|
|
221
|
+
v = self._aliases[v.lower()]
|
|
222
|
+
name = v[5:] if v.lower().startswith("tier:") else v
|
|
223
|
+
t = TIER_BY_NAME.get(name.lower()) if name else None
|
|
224
|
+
if t is not None:
|
|
225
|
+
models = self._models_at_tier(t)
|
|
226
|
+
return models[0] if models else None
|
|
227
|
+
return v
|
|
228
|
+
|
|
229
|
+
def resolve_full(
|
|
230
|
+
self, model: str | None, provider_hint: str | None, floor: int | None
|
|
231
|
+
) -> Resolution | None:
|
|
232
|
+
"""Request-time resolution with defaults, aliases, and a provider hint.
|
|
233
|
+
|
|
234
|
+
Precedence is 'model wins, provider is a hint':
|
|
235
|
+
- An explicit model routes normally; a provider hint only reorders the
|
|
236
|
+
candidate providers (or, if the model is otherwise unroutable, picks
|
|
237
|
+
the hinted provider).
|
|
238
|
+
- A missing/`default`/`auto` model falls back to the provider's default
|
|
239
|
+
(when a provider is named) or the global default.
|
|
240
|
+
|
|
241
|
+
Returns None only when no model can be determined at all (no model given
|
|
242
|
+
and no default configured). An undetermined-but-named model comes back as
|
|
243
|
+
a Resolution with empty providers (the caller turns that into a 404).
|
|
244
|
+
"""
|
|
245
|
+
hint = provider_hint.lower() if provider_hint else None
|
|
246
|
+
chosen = model
|
|
247
|
+
if not chosen or chosen.lower() in ("default", "auto"):
|
|
248
|
+
chosen = (self._provider_defaults.get(hint) if hint else None) or self._global_default
|
|
249
|
+
if not chosen:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
concrete = self.materialize(chosen) or chosen
|
|
253
|
+
res = self.resolve(concrete, floor=floor)
|
|
254
|
+
|
|
255
|
+
if hint:
|
|
256
|
+
if hint in res.providers:
|
|
257
|
+
res.providers = [hint] + [p for p in res.providers if p != hint]
|
|
258
|
+
elif not res.providers and hint in self._known_ids:
|
|
259
|
+
# Model didn't resolve on its own; honor the hint as the target.
|
|
260
|
+
res = Resolution(concrete, [hint], "provider-hint", tier=res.tier, floor=res.floor)
|
|
261
|
+
return res
|
|
262
|
+
|
|
263
|
+
def merge_provider_models(self, provider: str, models: list[str]) -> None:
|
|
264
|
+
"""Replace one provider's slice of the live index with `models`, then persist."""
|
|
265
|
+
for m in list(self._index):
|
|
266
|
+
self._index[m].discard(provider)
|
|
267
|
+
for m in models:
|
|
268
|
+
self._index.setdefault(m, set()).add(provider)
|
|
269
|
+
self._index = {m: ps for m, ps in self._index.items() if ps}
|
|
270
|
+
self._index_built_at = time.time()
|
|
271
|
+
self._rebuild_tier_index()
|
|
272
|
+
self._save_cached_index()
|
|
273
|
+
|
|
274
|
+
def tier_alternatives(self, tier: int, floor: int, exclude_model: str) -> list[tuple[str, str]]:
|
|
275
|
+
"""Find (model, provider) pairs in the index at the given tier (and >= floor).
|
|
276
|
+
|
|
277
|
+
Used when all providers for the primary model are exhausted and we want
|
|
278
|
+
to fall back to an equivalent-quality alternative.
|
|
279
|
+
"""
|
|
280
|
+
out: list[tuple[str, str]] = []
|
|
281
|
+
for t in sorted(self._tier_index, reverse=True):
|
|
282
|
+
if t < floor or t != tier:
|
|
283
|
+
continue
|
|
284
|
+
for m in self._tier_index.get(t, []):
|
|
285
|
+
if m == exclude_model:
|
|
286
|
+
continue
|
|
287
|
+
for pid in order_by_priority(
|
|
288
|
+
sorted(self._index.get(m, set())), self.settings.provider_priority
|
|
289
|
+
):
|
|
290
|
+
out.append((m, pid))
|
|
291
|
+
return out
|