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/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