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/cli.py
ADDED
|
@@ -0,0 +1,1481 @@
|
|
|
1
|
+
"""synckey command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.layout import Layout
|
|
14
|
+
from rich.live import Live
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .config import ensure_home, generate_unified_key, home, config_path
|
|
21
|
+
from .context import Context
|
|
22
|
+
from .db import sha256
|
|
23
|
+
from .state import Health
|
|
24
|
+
from .tiers import TIER_BY_NAME, TIER_NAMES, model_tier, model_price
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="synckey",
|
|
28
|
+
help="Merge every AI provider key behind one unified, OpenAI-compatible API key.",
|
|
29
|
+
no_args_is_help=False,
|
|
30
|
+
add_completion=False,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.callback(invoke_without_command=True)
|
|
35
|
+
def synckey_main(ctx: typer.Context, guide: bool = typer.Option(False, "--guide", help="Interactive walkthrough of synckey concepts.")):
|
|
36
|
+
"""synckey merge every AI provider behind one key."""
|
|
37
|
+
if guide:
|
|
38
|
+
_run_guide()
|
|
39
|
+
raise typer.Exit(0)
|
|
40
|
+
if ctx.invoked_subcommand is not None:
|
|
41
|
+
return
|
|
42
|
+
ctx.command.get_help(ctx)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
config_app = typer.Typer(help="View and edit routing preferences (defaults, aliases).")
|
|
46
|
+
app.add_typer(config_app, name="config")
|
|
47
|
+
tier_app = typer.Typer(help="Inspect and override model quality tiers.", no_args_is_help=True)
|
|
48
|
+
app.add_typer(tier_app, name="tier")
|
|
49
|
+
routing_app = typer.Typer(help="Routing preferences: provider priority and fallback presets.")
|
|
50
|
+
app.add_typer(routing_app, name="routing")
|
|
51
|
+
|
|
52
|
+
console = Console()
|
|
53
|
+
err_con = Console(stderr=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_ctx() -> Context:
|
|
57
|
+
ensure_home()
|
|
58
|
+
return Context()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def fmt_num(n: int | None) -> str:
|
|
62
|
+
n = n or 0
|
|
63
|
+
if n >= 1_000_000:
|
|
64
|
+
return f"{n / 1_000_000:.2f}M"
|
|
65
|
+
if n >= 1_000:
|
|
66
|
+
return f"{n / 1_000:.1f}k"
|
|
67
|
+
return str(n)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def fmt_cost(usd: float | None) -> str:
|
|
71
|
+
if usd is None:
|
|
72
|
+
return ""
|
|
73
|
+
if usd < 0.001:
|
|
74
|
+
return f"${usd * 1000:.3f}m" # milli-dollars
|
|
75
|
+
return f"${usd:.4f}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def health_text(h: Health, remaining: float = 0) -> Text:
|
|
79
|
+
if h == Health.DEAD:
|
|
80
|
+
return Text("DEAD", style="bold red")
|
|
81
|
+
if h == Health.COOLING:
|
|
82
|
+
return Text(f"COOLING {int(remaining)}s", style="yellow")
|
|
83
|
+
return Text("LIVE", style="green")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command()
|
|
87
|
+
def providers():
|
|
88
|
+
"""List every supported provider and where to get a key."""
|
|
89
|
+
ctx = load_ctx()
|
|
90
|
+
configured = set(ctx.db.providers_with_keys())
|
|
91
|
+
table = Table(title="Supported providers")
|
|
92
|
+
table.add_column("id", style="cyan", no_wrap=True)
|
|
93
|
+
table.add_column("name")
|
|
94
|
+
table.add_column("keys", justify="right")
|
|
95
|
+
table.add_column("auth", style="dim")
|
|
96
|
+
table.add_column("get a key", style="blue")
|
|
97
|
+
for pid, prov in sorted(ctx.providers.items()):
|
|
98
|
+
n = len(ctx.db.list_keys(provider=pid))
|
|
99
|
+
flag = f"[green]{n}[/]" if pid in configured else str(n)
|
|
100
|
+
table.add_row(pid, prov.name, flag, prov.auth, prov.signup or prov.notes or "")
|
|
101
|
+
console.print(table)
|
|
102
|
+
ctx.close()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _add_one_key(ctx, provider: str, secret: str, label: str | None, weight: int, rpm, tpm) -> int:
|
|
106
|
+
existing = len(ctx.db.list_keys(provider=provider))
|
|
107
|
+
lbl = label or f"{provider}-{existing + 1}"
|
|
108
|
+
key_id = ctx.db.add_key(provider, lbl, ctx.box.seal(secret), weight=weight, rpm_limit=rpm, tpm_limit=tpm)
|
|
109
|
+
masked = secret[-4:] if len(secret) >= 4 else "***"
|
|
110
|
+
limits = (f" RPM={rpm}" if rpm else "") + (f" TPM={fmt_num(int(tpm))}" if tpm else "")
|
|
111
|
+
console.print(
|
|
112
|
+
f"[green]Added[/] {ctx.providers[provider].name} key [cyan]#{key_id}[/] "
|
|
113
|
+
f"(label={lbl}, weight={weight}{limits}). Masked: ...{masked}"
|
|
114
|
+
)
|
|
115
|
+
return key_id
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _read_key_file(path: Path) -> list[str]:
|
|
119
|
+
"""Read keys from a file: one per line and/or comma-separated, '#' comments ignored."""
|
|
120
|
+
try:
|
|
121
|
+
text = Path(path).read_text()
|
|
122
|
+
except OSError as exc:
|
|
123
|
+
err_con.print(f"[red]Cannot read file:[/] {exc}")
|
|
124
|
+
raise typer.Exit(1)
|
|
125
|
+
out: list[str] = []
|
|
126
|
+
for line in text.splitlines():
|
|
127
|
+
line = line.strip()
|
|
128
|
+
if not line or line.startswith("#"):
|
|
129
|
+
continue
|
|
130
|
+
out.extend(s.strip() for s in line.split(",") if s.strip())
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _collect_secrets(ctx, provider: str, key, file, from_env) -> list[str]:
|
|
135
|
+
"""Gather one or many secrets from --key (comma ok), --file, --from-env, or a prompt."""
|
|
136
|
+
prov = ctx.providers[provider]
|
|
137
|
+
secrets: list[str] = []
|
|
138
|
+
if key:
|
|
139
|
+
secrets = [s.strip() for s in key.split(",") if s.strip()]
|
|
140
|
+
elif file:
|
|
141
|
+
secrets = _read_key_file(file)
|
|
142
|
+
elif from_env:
|
|
143
|
+
for env in prov.env:
|
|
144
|
+
val = os.environ.get(env, "").strip()
|
|
145
|
+
if val:
|
|
146
|
+
secrets.append(val)
|
|
147
|
+
console.print(f"[dim]Read key from ${env}[/]")
|
|
148
|
+
if not secrets:
|
|
149
|
+
err_con.print(f"[red]No key in env vars:[/] {', '.join(prov.env) or '(none)'}")
|
|
150
|
+
raise typer.Exit(1)
|
|
151
|
+
else:
|
|
152
|
+
entered = typer.prompt(
|
|
153
|
+
f"{prov.name} API key (one key, comma-separated keys, or a file path)",
|
|
154
|
+
hide_input=True,
|
|
155
|
+
).strip()
|
|
156
|
+
candidate = Path(entered)
|
|
157
|
+
if entered and candidate.exists() and candidate.is_file():
|
|
158
|
+
secrets = _read_key_file(candidate)
|
|
159
|
+
else:
|
|
160
|
+
secrets = [s.strip() for s in entered.split(",") if s.strip()]
|
|
161
|
+
if not secrets:
|
|
162
|
+
err_con.print("[red]No keys provided.[/]")
|
|
163
|
+
raise typer.Exit(1)
|
|
164
|
+
return secrets
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def _models_per_key(ctx, provider: str) -> dict[int, list[str]]:
|
|
168
|
+
"""Query each enabled key's /models endpoint; returns {key_id: [models]}."""
|
|
169
|
+
import httpx
|
|
170
|
+
|
|
171
|
+
keys = ctx.db.list_keys(provider=provider, enabled_only=True)
|
|
172
|
+
out: dict[int, list[str]] = {}
|
|
173
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
174
|
+
for k in keys:
|
|
175
|
+
try:
|
|
176
|
+
secret = ctx.box.open(k.secret)
|
|
177
|
+
out[k.id] = await ctx.router._fetch_models(client, provider, secret)
|
|
178
|
+
except Exception:
|
|
179
|
+
out[k.id] = []
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _show_models_per_key(ctx, provider: str, per_key: dict[int, list[str]]) -> list[str]:
|
|
184
|
+
"""Print fetched models (per-key when keys differ) and return the union."""
|
|
185
|
+
keys = {k.id: k for k in ctx.db.list_keys(provider=provider)}
|
|
186
|
+
sets = {kid: set(ms) for kid, ms in per_key.items()}
|
|
187
|
+
union = sorted(set().union(*sets.values())) if sets else []
|
|
188
|
+
differ = len({frozenset(s) for s in sets.values()}) > 1
|
|
189
|
+
|
|
190
|
+
if not union:
|
|
191
|
+
console.print(f"[yellow]No models returned for {provider}.[/] Check the key(s).")
|
|
192
|
+
return []
|
|
193
|
+
|
|
194
|
+
if differ:
|
|
195
|
+
console.print(f"[yellow]Keys for {provider} expose different model sets:[/]")
|
|
196
|
+
common = set.intersection(*sets.values()) if sets else set()
|
|
197
|
+
for kid, ms in sets.items():
|
|
198
|
+
lbl = keys[kid].label if kid in keys else f"#{kid}"
|
|
199
|
+
extra = sorted(ms - common)
|
|
200
|
+
console.print(
|
|
201
|
+
f" [cyan]#{kid}[/] {lbl}: {len(ms)} models"
|
|
202
|
+
+ (f" [dim](unique: {', '.join(extra[:6])}{'...' if len(extra) > 6 else ''})[/]" if extra else "")
|
|
203
|
+
)
|
|
204
|
+
table = Table(title=f"{provider}: {len(union)} models")
|
|
205
|
+
table.add_column("model", style="cyan")
|
|
206
|
+
table.add_column("tier")
|
|
207
|
+
for m in union:
|
|
208
|
+
tn = TIER_NAMES.get(model_tier(m) or 0, "")
|
|
209
|
+
table.add_row(m, tn)
|
|
210
|
+
console.print(table)
|
|
211
|
+
return union
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _post_add_flow(ctx, provider: str, fetch_opt: bool | None, default_opt: str | None) -> None:
|
|
215
|
+
"""After keys are added: optionally fetch models, then set this provider's default."""
|
|
216
|
+
interactive = sys.stdin.isatty()
|
|
217
|
+
union: list[str] = []
|
|
218
|
+
|
|
219
|
+
do_fetch = fetch_opt
|
|
220
|
+
if do_fetch is None:
|
|
221
|
+
do_fetch = (
|
|
222
|
+
typer.confirm(f"Fetch available models for {provider} now?", default=True)
|
|
223
|
+
if interactive
|
|
224
|
+
else False
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if do_fetch:
|
|
228
|
+
console.print(f"[dim]Querying {provider}...[/]")
|
|
229
|
+
per_key = asyncio.run(_models_per_key(ctx, provider))
|
|
230
|
+
union = _show_models_per_key(ctx, provider, per_key)
|
|
231
|
+
if union:
|
|
232
|
+
ctx.router.merge_provider_models(provider, union)
|
|
233
|
+
|
|
234
|
+
default = default_opt
|
|
235
|
+
if default is None and interactive:
|
|
236
|
+
example = union[0] if union else "llama-3.3-70b"
|
|
237
|
+
prompt_txt = f"Default model for {provider} (model id like '{example}', or tier: frontier/high/mid/low)"
|
|
238
|
+
default = typer.prompt(prompt_txt, default="", show_default=False).strip() or None
|
|
239
|
+
|
|
240
|
+
if default:
|
|
241
|
+
if union and default not in union and default.lower() not in ("frontier", "high", "mid", "low"):
|
|
242
|
+
console.print(f"[yellow]Note:[/] '{default}' wasn't in the fetched list; saving anyway.")
|
|
243
|
+
ctx.prefs.set_provider_default(provider, default)
|
|
244
|
+
console.print(f"[green]Set {provider} default ->[/] [cyan]{default}[/]")
|
|
245
|
+
if not ctx.prefs.global_default():
|
|
246
|
+
if not interactive or typer.confirm(
|
|
247
|
+
f"No global default set. Use '{default}' as the global default too?", default=True
|
|
248
|
+
):
|
|
249
|
+
ctx.prefs.set_global_default(default)
|
|
250
|
+
console.print(f"[green]Set global default ->[/] [cyan]{default}[/]")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@app.command()
|
|
254
|
+
def models(
|
|
255
|
+
provider: str = typer.Option(None, "--provider", "-p"),
|
|
256
|
+
refresh: bool = typer.Option(False, "--refresh", "-r"),
|
|
257
|
+
tier: str = typer.Option(None, "--tier", "-t", help="Filter by tier: frontier|high|mid|low"),
|
|
258
|
+
):
|
|
259
|
+
"""List the models your configured keys can actually call."""
|
|
260
|
+
ctx = load_ctx()
|
|
261
|
+
if refresh or not ctx.router.index:
|
|
262
|
+
console.print("[dim]Querying providers...[/]")
|
|
263
|
+
discovered = asyncio.run(ctx.router.refresh_index(ctx.first_secret))
|
|
264
|
+
for pid, ms in discovered.items():
|
|
265
|
+
color = "green" if ms else "red"
|
|
266
|
+
console.print(f" [{color}]{pid}[/]: {len(ms)} models")
|
|
267
|
+
|
|
268
|
+
index = ctx.router.index
|
|
269
|
+
if not index:
|
|
270
|
+
console.print("[yellow]No models.[/] Add keys then run `synckey models -r`.")
|
|
271
|
+
ctx.close()
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
rows = []
|
|
275
|
+
for model, provs in sorted(index.items()):
|
|
276
|
+
t = model_tier(model)
|
|
277
|
+
tier_name = TIER_NAMES.get(t or 0, "unknown")
|
|
278
|
+
if tier and tier_name != tier:
|
|
279
|
+
continue
|
|
280
|
+
for pid in sorted(provs):
|
|
281
|
+
if provider and pid != provider.lower():
|
|
282
|
+
continue
|
|
283
|
+
rows.append((model, pid, tier_name))
|
|
284
|
+
|
|
285
|
+
table = Table(title=f"Usable models ({len(rows)})")
|
|
286
|
+
table.add_column("model", style="cyan")
|
|
287
|
+
table.add_column("provider")
|
|
288
|
+
table.add_column("tier")
|
|
289
|
+
for model, pid, tier_name in rows:
|
|
290
|
+
color = {"frontier": "red", "high": "magenta", "mid": "yellow", "low": "dim"}.get(tier_name, "")
|
|
291
|
+
table.add_row(model, pid, f"[{color}]{tier_name}[/]" if color else tier_name)
|
|
292
|
+
console.print(table)
|
|
293
|
+
console.print(f"[dim]Index age: {int(ctx.router.index_age())}s. Use --refresh to update.[/]")
|
|
294
|
+
ctx.close()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@config_app.callback(invoke_without_command=True)
|
|
298
|
+
def config_main(ctx_: typer.Context):
|
|
299
|
+
"""Show routing preferences when called with no subcommand."""
|
|
300
|
+
if ctx_.invoked_subcommand is not None:
|
|
301
|
+
return
|
|
302
|
+
ctx = load_ctx()
|
|
303
|
+
s = ctx.settings
|
|
304
|
+
p = ctx.prefs
|
|
305
|
+
lines = [
|
|
306
|
+
f"global default: [cyan]{p.global_default() or '(none)'}[/]",
|
|
307
|
+
f"gateway: http://{s.host}:{s.port}",
|
|
308
|
+
f"provider priority:{(' ' + ' > '.join(s.provider_priority)) if s.provider_priority else ' (none)'}",
|
|
309
|
+
f"tier fallback: {'on' if s.tier_fallback_enabled else 'off'}",
|
|
310
|
+
f"cross-provider: {'on' if s.cross_provider_fallback else 'off'}",
|
|
311
|
+
f"deferred queue: {'on' if s.deferred_enabled else 'off'} (TTL {int(s.deferred_ttl)}s)",
|
|
312
|
+
]
|
|
313
|
+
console.print(Panel.fit("\n".join(lines), title="Settings", border_style="cyan"))
|
|
314
|
+
|
|
315
|
+
pdefs = p.provider_defaults()
|
|
316
|
+
if pdefs:
|
|
317
|
+
t = Table(title="Per-provider defaults")
|
|
318
|
+
t.add_column("provider", style="cyan")
|
|
319
|
+
t.add_column("default model")
|
|
320
|
+
for prov, model in sorted(pdefs.items()):
|
|
321
|
+
t.add_row(prov, model)
|
|
322
|
+
console.print(t)
|
|
323
|
+
|
|
324
|
+
aliases = p.aliases()
|
|
325
|
+
if aliases:
|
|
326
|
+
t = Table(title="Aliases")
|
|
327
|
+
t.add_column("alias", style="cyan")
|
|
328
|
+
t.add_column("-> target")
|
|
329
|
+
for name, target in sorted(aliases.items()):
|
|
330
|
+
t.add_row(name, target)
|
|
331
|
+
console.print(t)
|
|
332
|
+
|
|
333
|
+
tov = {**s.tier_overrides, **p.tier_overrides()}
|
|
334
|
+
if tov:
|
|
335
|
+
t = Table(title="Tier overrides")
|
|
336
|
+
t.add_column("model", style="cyan")
|
|
337
|
+
t.add_column("tier")
|
|
338
|
+
for model, tier in sorted(tov.items()):
|
|
339
|
+
t.add_row(model, str(tier))
|
|
340
|
+
console.print(t)
|
|
341
|
+
|
|
342
|
+
if not pdefs and not aliases:
|
|
343
|
+
console.print(
|
|
344
|
+
"[dim]No defaults or aliases yet. Try `synckey config set-default <model>` "
|
|
345
|
+
"or `synckey config alias fast mid`.[/]"
|
|
346
|
+
)
|
|
347
|
+
ctx.close()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@config_app.command("set-default")
|
|
351
|
+
def config_set_default(model: str = typer.Argument(..., help="Model id (e.g. gpt-4o), a tier (frontier/high/mid/low), or an alias.")):
|
|
352
|
+
"""Set the global default model (used when a request names no model)."""
|
|
353
|
+
ctx = load_ctx()
|
|
354
|
+
ctx.prefs.set_global_default(model)
|
|
355
|
+
console.print(f"[green]Global default ->[/] [cyan]{model}[/]")
|
|
356
|
+
ctx.close()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@config_app.command("provider-default")
|
|
360
|
+
def config_provider_default(
|
|
361
|
+
provider: str = typer.Argument(..., help="Provider id, e.g. groq."),
|
|
362
|
+
model: str = typer.Argument(..., help="Model id (e.g. llama-3.3-70b), a tier (frontier/high/mid/low), or an alias."),
|
|
363
|
+
):
|
|
364
|
+
"""Set a provider's default model (used when a request names only that provider)."""
|
|
365
|
+
ctx = load_ctx()
|
|
366
|
+
if provider.lower() not in ctx.providers:
|
|
367
|
+
err_con.print(f"[red]Unknown provider '{provider}'.[/]")
|
|
368
|
+
raise typer.Exit(1)
|
|
369
|
+
ctx.prefs.set_provider_default(provider, model)
|
|
370
|
+
console.print(f"[green]{provider} default ->[/] [cyan]{model}[/]")
|
|
371
|
+
ctx.close()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@config_app.command("alias")
|
|
375
|
+
def config_alias(
|
|
376
|
+
name: str = typer.Argument(..., help="Alias name, e.g. fast."),
|
|
377
|
+
target: str = typer.Argument(..., help="A model id or tier (frontier|high|mid|low)."),
|
|
378
|
+
):
|
|
379
|
+
"""Define an alias that clients can send as the model (e.g. fast -> mid)."""
|
|
380
|
+
ctx = load_ctx()
|
|
381
|
+
ctx.prefs.set_alias(name, target)
|
|
382
|
+
console.print(f"[green]Alias[/] [cyan]{name}[/] -> [cyan]{target}[/]")
|
|
383
|
+
ctx.close()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@config_app.command("unalias")
|
|
387
|
+
def config_unalias(name: str = typer.Argument(...)):
|
|
388
|
+
"""Remove an alias."""
|
|
389
|
+
ctx = load_ctx()
|
|
390
|
+
if ctx.prefs.remove_alias(name):
|
|
391
|
+
console.print(f"[green]Removed alias[/] {name}.")
|
|
392
|
+
else:
|
|
393
|
+
err_con.print(f"[yellow]No alias '{name}'.[/]")
|
|
394
|
+
ctx.close()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@tier_app.command("set")
|
|
398
|
+
def tier_set(
|
|
399
|
+
model: str = typer.Argument(..., help="Model id (or substring you call)."),
|
|
400
|
+
tier: str = typer.Argument(..., help="frontier | high | mid | low"),
|
|
401
|
+
):
|
|
402
|
+
"""Override a model's quality tier (persists; overrides auto-detection)."""
|
|
403
|
+
ctx = load_ctx()
|
|
404
|
+
if tier.lower() not in TIER_BY_NAME:
|
|
405
|
+
err_con.print(f"[red]Tier must be one of:[/] {', '.join(TIER_BY_NAME)}")
|
|
406
|
+
raise typer.Exit(1)
|
|
407
|
+
ctx.prefs.set_tier_override(model, tier.lower())
|
|
408
|
+
console.print(f"[green]Tier[/] [cyan]{model}[/] -> [bold]{tier.lower()}[/]")
|
|
409
|
+
ctx.close()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@tier_app.command("unset")
|
|
413
|
+
def tier_unset(model: str = typer.Argument(...)):
|
|
414
|
+
"""Remove a manual tier override (revert to auto-detection)."""
|
|
415
|
+
ctx = load_ctx()
|
|
416
|
+
if ctx.prefs.remove_tier_override(model):
|
|
417
|
+
console.print(f"[green]Reverted[/] {model} to auto-detected tier.")
|
|
418
|
+
else:
|
|
419
|
+
err_con.print(f"[yellow]No override for '{model}'.[/]")
|
|
420
|
+
ctx.close()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@tier_app.command("list")
|
|
424
|
+
def tier_list():
|
|
425
|
+
"""Show manual tier overrides."""
|
|
426
|
+
ctx = load_ctx()
|
|
427
|
+
overrides = {**ctx.settings.tier_overrides, **ctx.prefs.tier_overrides()}
|
|
428
|
+
if not overrides:
|
|
429
|
+
console.print("[dim]No tier overrides. All tiers are auto-detected.[/]")
|
|
430
|
+
console.print("[dim]Set one: `synckey tier set my-model frontier`[/]")
|
|
431
|
+
ctx.close()
|
|
432
|
+
return
|
|
433
|
+
t = Table(title="Tier overrides")
|
|
434
|
+
t.add_column("model", style="cyan")
|
|
435
|
+
t.add_column("tier")
|
|
436
|
+
for model, tier in sorted(overrides.items()):
|
|
437
|
+
t.add_row(model, str(tier))
|
|
438
|
+
console.print(t)
|
|
439
|
+
ctx.close()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@app.command("keys")
|
|
443
|
+
def keys(
|
|
444
|
+
provider: str = typer.Argument(None),
|
|
445
|
+
rm: int = typer.Option(None, "--rm", help="Remove a key by its id."),
|
|
446
|
+
enable: int = typer.Option(None, "--enable", help="Re-enable a dead or disabled key."),
|
|
447
|
+
disable: int = typer.Option(None, "--disable", help="Disable a key without deleting it."),
|
|
448
|
+
):
|
|
449
|
+
"""List all stored keys, or manage them with flags.
|
|
450
|
+
|
|
451
|
+
Examples:
|
|
452
|
+
synckey keys list all keys
|
|
453
|
+
synckey keys --rm 3 remove key #3
|
|
454
|
+
synckey keys --enable 3 re-enable key #3
|
|
455
|
+
synckey keys --disable 3 disable key #3
|
|
456
|
+
"""
|
|
457
|
+
ctx = load_ctx()
|
|
458
|
+
|
|
459
|
+
if rm is not None:
|
|
460
|
+
if ctx.db.remove_key(rm):
|
|
461
|
+
console.print(f"[green]Removed[/] key #{rm}.")
|
|
462
|
+
else:
|
|
463
|
+
err_con.print(f"[red]No key #{rm}.[/]")
|
|
464
|
+
ctx.close()
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
if enable is not None:
|
|
468
|
+
ctx.db.set_key_enabled(enable, True)
|
|
469
|
+
ctx.states.set_live(enable)
|
|
470
|
+
console.print(f"[green]Enabled[/] key #{enable}.")
|
|
471
|
+
ctx.close()
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
if disable is not None:
|
|
475
|
+
ctx.db.set_key_enabled(disable, False)
|
|
476
|
+
console.print(f"[yellow]Disabled[/] key #{disable}.")
|
|
477
|
+
ctx.close()
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
keys = ctx.db.list_keys(provider=provider.lower() if provider else None)
|
|
481
|
+
if not keys:
|
|
482
|
+
console.print("[yellow]No keys stored.[/] Run `synckey setup` to add one.")
|
|
483
|
+
ctx.close()
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
states = ctx.states.all()
|
|
487
|
+
stats = ctx.db.key_stats()
|
|
488
|
+
|
|
489
|
+
table = Table(title="Keys (remove: synckey keys --rm <id>)")
|
|
490
|
+
table.add_column("id", style="cyan", no_wrap=True)
|
|
491
|
+
table.add_column("provider")
|
|
492
|
+
table.add_column("health")
|
|
493
|
+
table.add_column("requests", justify="right")
|
|
494
|
+
table.add_column("errors", justify="right")
|
|
495
|
+
table.add_column("cost", justify="right")
|
|
496
|
+
|
|
497
|
+
for k in keys:
|
|
498
|
+
st = states.get(k.id)
|
|
499
|
+
from .state import KeyState
|
|
500
|
+
ks = st if st else KeyState()
|
|
501
|
+
ht = health_text(ks.health, ks.cooldown_remaining())
|
|
502
|
+
s = stats.get(k.id, {})
|
|
503
|
+
table.add_row(
|
|
504
|
+
str(k.id),
|
|
505
|
+
k.provider,
|
|
506
|
+
ht,
|
|
507
|
+
str(s.get("requests", 0)),
|
|
508
|
+
str(s.get("errors", 0)),
|
|
509
|
+
fmt_cost(s.get("cost")),
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
console.print(table)
|
|
513
|
+
ctx.close()
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
@app.command()
|
|
517
|
+
def setup(force: bool = typer.Option(False, "--force", help="Regenerate the unified API key.")):
|
|
518
|
+
"""Add or update provider API keys (interactive guided setup)."""
|
|
519
|
+
ensure_home()
|
|
520
|
+
ctx = Context()
|
|
521
|
+
|
|
522
|
+
# 1. Unified key
|
|
523
|
+
if force:
|
|
524
|
+
unified = generate_unified_key()
|
|
525
|
+
ctx.db.set_meta("unified_key_hash", sha256(unified))
|
|
526
|
+
ctx.db.set_meta("created_at", str(time.time()))
|
|
527
|
+
_ = ctx.box
|
|
528
|
+
console.print(
|
|
529
|
+
Panel.fit(
|
|
530
|
+
f"Your new unified API key (shown once — store it now):\n\n [bold cyan]{unified}[/]",
|
|
531
|
+
title="Unified key regenerated",
|
|
532
|
+
border_style="green",
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
elif not ctx.unified_key_hash():
|
|
536
|
+
unified = generate_unified_key()
|
|
537
|
+
ctx.db.set_meta("unified_key_hash", sha256(unified))
|
|
538
|
+
ctx.db.set_meta("created_at", str(time.time()))
|
|
539
|
+
_ = ctx.box
|
|
540
|
+
console.print(
|
|
541
|
+
Panel.fit(
|
|
542
|
+
f"Your unified API key (shown once — store it now):\n\n [bold cyan]{unified}[/]",
|
|
543
|
+
title="Unified key minted",
|
|
544
|
+
border_style="green",
|
|
545
|
+
)
|
|
546
|
+
)
|
|
547
|
+
else:
|
|
548
|
+
console.print("[dim]Already initialized — keeping your existing unified key.[/]")
|
|
549
|
+
|
|
550
|
+
# 2. Add providers
|
|
551
|
+
console.print("\n[bold]Add provider keys.[/]")
|
|
552
|
+
added_any = False
|
|
553
|
+
while True:
|
|
554
|
+
if added_any:
|
|
555
|
+
prompt = "Add another provider and keys?"
|
|
556
|
+
else:
|
|
557
|
+
prompt = "Add a provider and keys?"
|
|
558
|
+
add_more = (
|
|
559
|
+
typer.confirm(prompt, default=False)
|
|
560
|
+
if sys.stdin.isatty()
|
|
561
|
+
else False
|
|
562
|
+
)
|
|
563
|
+
if not add_more:
|
|
564
|
+
break
|
|
565
|
+
provider = typer.prompt(
|
|
566
|
+
"Provider id (e.g. groq, gemini, openai)",
|
|
567
|
+
default="", show_default=False,
|
|
568
|
+
).strip().lower()
|
|
569
|
+
if not provider:
|
|
570
|
+
break
|
|
571
|
+
if provider not in ctx.providers:
|
|
572
|
+
err_con.print(f"[red]Unknown provider '{provider}'.[/] Run `synckey providers` for the list.")
|
|
573
|
+
continue
|
|
574
|
+
secrets = _collect_secrets(ctx, provider, None, None, False)
|
|
575
|
+
for s in secrets:
|
|
576
|
+
_add_one_key(ctx, provider, s, None, 1, None, None)
|
|
577
|
+
_post_add_flow(ctx, provider, None, None)
|
|
578
|
+
added_any = True
|
|
579
|
+
console.print()
|
|
580
|
+
if not ctx.prefs.global_default():
|
|
581
|
+
pick = typer.prompt(
|
|
582
|
+
"Global default model (model id like 'gpt-4o', or a tier: frontier/high/mid/low, or an alias)",
|
|
583
|
+
default="", show_default=False,
|
|
584
|
+
).strip()
|
|
585
|
+
if pick:
|
|
586
|
+
ctx.prefs.set_global_default(pick)
|
|
587
|
+
console.print(f"[green]Global default ->[/] [cyan]{pick}[/]")
|
|
588
|
+
|
|
589
|
+
# 4. Summary
|
|
590
|
+
configured = ctx.db.providers_with_keys()
|
|
591
|
+
first_provider = configured[0] if configured else None
|
|
592
|
+
default_model = ctx.prefs.global_default() or "llama-3.3-70b-versatile"
|
|
593
|
+
|
|
594
|
+
curl_examples = ""
|
|
595
|
+
if first_provider:
|
|
596
|
+
curl_examples = (
|
|
597
|
+
f"\n"
|
|
598
|
+
f"[bold]Try it:[/]\n"
|
|
599
|
+
f" curl http://localhost:8080/v1/chat/completions \\\n"
|
|
600
|
+
f" -H 'Authorization: Bearer YOUR_SYNCKEY_KEY' \\\n"
|
|
601
|
+
f" -H 'Content-Type: application/json' \\\n"
|
|
602
|
+
f" -d '{{\"model\": \"{default_model}\", \"messages\": [{{\"role\": \"user\", \"content\": \"Hi\"}}]}}'\n\n"
|
|
603
|
+
f"[bold]With a specific provider:[/]\n"
|
|
604
|
+
f" curl http://localhost:8080/v1/chat/completions \\\n"
|
|
605
|
+
f" -H 'Authorization: Bearer YOUR_SYNCKEY_KEY' \\\n"
|
|
606
|
+
f" -H 'Content-Type: application/json' \\\n"
|
|
607
|
+
f" -d '{{\"model\": \"{first_provider}/{default_model}\", \"messages\": [{{\"role\": \"user\", \"content\": \"Hi\"}}]}}'\n"
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
console.print(
|
|
611
|
+
Panel.fit(
|
|
612
|
+
f"providers: {', '.join(configured) or '(none)'}\n"
|
|
613
|
+
f"models indexed: {len(ctx.router.index)}\n"
|
|
614
|
+
f"global default: {default_model}\n\n"
|
|
615
|
+
f"Start the gateway: [bold]synckey serve[/]\n"
|
|
616
|
+
f"Health-check keys: [bold]synckey test[/]\n"
|
|
617
|
+
f"Watch it live: [bold]synckey dash[/]"
|
|
618
|
+
f"{curl_examples}",
|
|
619
|
+
title="Setup complete",
|
|
620
|
+
border_style="green",
|
|
621
|
+
)
|
|
622
|
+
)
|
|
623
|
+
ctx.close()
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _run_guide():
|
|
627
|
+
"""Interactive walkthrough of synckey concepts."""
|
|
628
|
+
console.print(Panel.fit(
|
|
629
|
+
"[bold]synckey guide[/] let's walk through how it all works.\n"
|
|
630
|
+
"Press Enter at each step, or Ctrl+C to quit anytime.",
|
|
631
|
+
border_style="cyan",
|
|
632
|
+
))
|
|
633
|
+
time.sleep(0.5)
|
|
634
|
+
|
|
635
|
+
console.print("\n[bold cyan]1. The unified key[/]")
|
|
636
|
+
console.print(" synckey gives you ONE key that works with ALL providers.")
|
|
637
|
+
console.print(" Your app uses this key synckey routes to the right provider.")
|
|
638
|
+
input("\n Press Enter to continue...")
|
|
639
|
+
|
|
640
|
+
console.print("\n[bold cyan]2. How routing works[/]")
|
|
641
|
+
console.print(" Send a model name → synckey finds which provider has it.")
|
|
642
|
+
console.print(" Send 'provider/model' → forces that provider.")
|
|
643
|
+
console.print(" Send nothing → uses your global default model.")
|
|
644
|
+
console.print(" Send only provider → uses that provider's default model.")
|
|
645
|
+
input("\n Press Enter to continue...")
|
|
646
|
+
|
|
647
|
+
console.print("\n[bold cyan]3. The fallback chain[/]")
|
|
648
|
+
console.print(" Every model has a quality tier: FRONTIER > HIGH > MID > LOW")
|
|
649
|
+
console.print(" If your first choice is rate-limited, synckey finds another at")
|
|
650
|
+
console.print(" the SAME tier (never drops to a cheaper tier).")
|
|
651
|
+
console.print(" Override per request: -H 'X-Quality-Floor: high'")
|
|
652
|
+
input("\n Press Enter to continue...")
|
|
653
|
+
|
|
654
|
+
console.print("\n[bold cyan]4. When all keys are cooling[/]")
|
|
655
|
+
console.print(" Instead of failing, synckey QUEUES your request and gives you")
|
|
656
|
+
console.print(" a poll URL. It runs automatically when a key frees up.")
|
|
657
|
+
console.print(" GET /v1/requests/<id> to check the result.")
|
|
658
|
+
input("\n Press Enter to continue...")
|
|
659
|
+
|
|
660
|
+
console.print("\n[bold cyan]5. Routing presets[/]")
|
|
661
|
+
ctx = load_ctx()
|
|
662
|
+
preset = "custom"
|
|
663
|
+
if ctx.settings.tier_fallback_enabled and ctx.settings.deep_cooling_threshold == 60.0:
|
|
664
|
+
preset = "reliable"
|
|
665
|
+
elif not ctx.settings.tier_fallback_enabled:
|
|
666
|
+
preset = "cheap"
|
|
667
|
+
console.print(f" Current preset: [cyan]{preset}[/]")
|
|
668
|
+
console.print(" reliable = tier fallback on, deep_cooling_threshold=60s")
|
|
669
|
+
console.print(" cheap = tier fallback off (prefer cost over quality)")
|
|
670
|
+
console.print(" Run `synckey routing preset reliable` or `synckey routing preset cheap`")
|
|
671
|
+
ctx.close()
|
|
672
|
+
input("\n Press Enter to continue...")
|
|
673
|
+
|
|
674
|
+
console.print("\n[bold cyan]6. Try it[/]")
|
|
675
|
+
console.print(" synckey serve start the gateway")
|
|
676
|
+
console.print(" synckey dash live dashboard")
|
|
677
|
+
console.print(" synckey test groq llama-3.3-70b-versatile 5 test a route with 5 calls")
|
|
678
|
+
console.print(" synckey models see which models are available")
|
|
679
|
+
console.print("")
|
|
680
|
+
console.print(Panel.fit(
|
|
681
|
+
"[bold green]That's it![/] Run `synckey --help` anytime for the full command reference.",
|
|
682
|
+
border_style="green",
|
|
683
|
+
))
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
@routing_app.command("priority")
|
|
687
|
+
def routing_priority(
|
|
688
|
+
providers: str = typer.Argument(None, help="Space-separated provider list, e.g. groq gemini."),
|
|
689
|
+
):
|
|
690
|
+
"""Show or set provider priority order (first = preferred when multiple have the model)."""
|
|
691
|
+
ctx = load_ctx()
|
|
692
|
+
if providers:
|
|
693
|
+
plist = [p.strip().lower() for p in providers.split() if p.strip()]
|
|
694
|
+
for p in plist:
|
|
695
|
+
if p not in ctx.providers:
|
|
696
|
+
err_con.print(f"[red]Unknown provider:[/] {p}")
|
|
697
|
+
err_con.print(f"[dim]Run `synckey providers` for the list.[/]")
|
|
698
|
+
ctx.close()
|
|
699
|
+
raise typer.Exit(1)
|
|
700
|
+
ctx.settings.provider_priority = plist
|
|
701
|
+
ctx.settings.save()
|
|
702
|
+
console.print(f"[green]Priority ->[/] {' > '.join(plist)}")
|
|
703
|
+
else:
|
|
704
|
+
current = ctx.settings.provider_priority
|
|
705
|
+
all_providers = sorted(ctx.providers.keys())
|
|
706
|
+
if current:
|
|
707
|
+
console.print(f"[cyan]Current priority:[/] {' > '.join(current)}")
|
|
708
|
+
else:
|
|
709
|
+
console.print("[dim]No priority set (uses provider order from config or default).[/]")
|
|
710
|
+
console.print(f"[dim]All providers:[/] {', '.join(all_providers)}")
|
|
711
|
+
ctx.close()
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
@routing_app.command("preset")
|
|
715
|
+
def routing_preset(
|
|
716
|
+
name: str = typer.Argument(None, help="reliable | cheap | (empty to show current)"),
|
|
717
|
+
):
|
|
718
|
+
"""Set or show the routing fallback preset.
|
|
719
|
+
|
|
720
|
+
reliable: tier fallback ON, deep_cooling_threshold=60s
|
|
721
|
+
Never drops below the requested model's tier.
|
|
722
|
+
Jumps to same-tier alternative after 60s of cooling.
|
|
723
|
+
|
|
724
|
+
cheap: tier fallback OFF
|
|
725
|
+
Uses whatever key is available, regardless of tier.
|
|
726
|
+
Can fall from FRONTIER to LOW if that's what's cheap/available.
|
|
727
|
+
"""
|
|
728
|
+
ctx = load_ctx()
|
|
729
|
+
if name is None:
|
|
730
|
+
tfe = ctx.settings.tier_fallback_enabled
|
|
731
|
+
dct = ctx.settings.deep_cooling_threshold
|
|
732
|
+
if tfe and dct == 60.0:
|
|
733
|
+
console.print(f"[cyan]Current preset:[/] [green]reliable[/]")
|
|
734
|
+
console.print(" tier_fallback: on")
|
|
735
|
+
console.print(" deep_cooling_threshold: 60s")
|
|
736
|
+
elif not tfe:
|
|
737
|
+
console.print(f"[cyan]Current preset:[/] [yellow]cheap[/]")
|
|
738
|
+
console.print(" tier_fallback: off")
|
|
739
|
+
console.print(" deep_cooling_threshold: 60s")
|
|
740
|
+
else:
|
|
741
|
+
console.print(f"[cyan]Current preset:[/] [dim]custom[/]")
|
|
742
|
+
console.print(f" tier_fallback: {tfe}")
|
|
743
|
+
console.print(f" deep_cooling_threshold: {dct}s")
|
|
744
|
+
console.print("\n[dim]Set: synckey routing preset reliable | cheap[/]")
|
|
745
|
+
ctx.close()
|
|
746
|
+
return
|
|
747
|
+
|
|
748
|
+
name = name.lower().strip()
|
|
749
|
+
if name not in ("reliable", "cheap"):
|
|
750
|
+
err_con.print("[red]Preset must be:[/] reliable | cheap")
|
|
751
|
+
raise typer.Exit(1)
|
|
752
|
+
|
|
753
|
+
if name == "reliable":
|
|
754
|
+
ctx.settings.tier_fallback_enabled = True
|
|
755
|
+
ctx.settings.deep_cooling_threshold = 60.0
|
|
756
|
+
else: # cheap
|
|
757
|
+
ctx.settings.tier_fallback_enabled = False
|
|
758
|
+
ctx.settings.deep_cooling_threshold = 60.0
|
|
759
|
+
|
|
760
|
+
ctx.settings.save()
|
|
761
|
+
console.print(f"[green]Preset set to:[/] [bold]{name}[/]")
|
|
762
|
+
ctx.close()
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
@app.command()
|
|
766
|
+
def serve(
|
|
767
|
+
host: str = typer.Option(None, "--host", "-h"),
|
|
768
|
+
port: int = typer.Option(None, "--port", "-p"),
|
|
769
|
+
):
|
|
770
|
+
"""Run the unified OpenAI-compatible gateway (background)."""
|
|
771
|
+
import subprocess
|
|
772
|
+
import sys
|
|
773
|
+
import os
|
|
774
|
+
|
|
775
|
+
ctx = load_ctx()
|
|
776
|
+
if not ctx.unified_key_hash():
|
|
777
|
+
err_con.print("[red]Not initialized.[/] Run `synckey setup` first.")
|
|
778
|
+
raise typer.Exit(1)
|
|
779
|
+
if not ctx.db.providers_with_keys():
|
|
780
|
+
err_con.print("[yellow]Warning:[/] no provider keys stored yet. Run `synckey setup` to add one.")
|
|
781
|
+
|
|
782
|
+
bind_host = host or ctx.settings.host
|
|
783
|
+
bind_port = port or ctx.settings.port
|
|
784
|
+
|
|
785
|
+
states = ctx.states.all()
|
|
786
|
+
from .state import Health as H
|
|
787
|
+
live = sum(1 for s in states.values() if s.health == H.LIVE)
|
|
788
|
+
dead = sum(1 for s in states.values() if s.health == H.DEAD)
|
|
789
|
+
|
|
790
|
+
console.print(
|
|
791
|
+
Panel.fit(
|
|
792
|
+
f"[bold green]synckey gateway[/]\n\n"
|
|
793
|
+
f"OpenAI base_url: [cyan]http://{bind_host}:{bind_port}/v1[/]\n"
|
|
794
|
+
f"providers: {', '.join(ctx.db.providers_with_keys()) or '(none)'}\n"
|
|
795
|
+
f"models indexed: {len(ctx.router.index)}\n"
|
|
796
|
+
f"keys live/dead: {live}/{dead}\n"
|
|
797
|
+
f"default model: {ctx.prefs.global_default() or '(none clients must send model)'}\n"
|
|
798
|
+
f"max_retries: {ctx.settings.max_retries}\n"
|
|
799
|
+
f"tier fallback: {'on' if ctx.settings.tier_fallback_enabled else 'off'}\n"
|
|
800
|
+
f"deferred queue: {'on' if ctx.settings.deferred_enabled else 'off'}"
|
|
801
|
+
f" (202 + poll when all keys cooling; TTL {int(ctx.settings.deferred_ttl)}s)\n"
|
|
802
|
+
f"quality floor: per-request via X-Quality-Floor header",
|
|
803
|
+
border_style="green",
|
|
804
|
+
)
|
|
805
|
+
)
|
|
806
|
+
ctx.close()
|
|
807
|
+
|
|
808
|
+
srv = os.path.join(os.path.dirname(__file__), "_serve_temp.py")
|
|
809
|
+
with open(srv, "w") as f:
|
|
810
|
+
f.write("import uvicorn\n")
|
|
811
|
+
f.write("from synckey.server import create_app\n")
|
|
812
|
+
f.write("from synckey.context import Context\n")
|
|
813
|
+
f.write(f"uvicorn.run(create_app(Context()), host='{bind_host}', port={bind_port}, log_level='warning', access_log=False)\n")
|
|
814
|
+
|
|
815
|
+
kwargs = {"cwd": os.getcwd()}
|
|
816
|
+
if sys.platform == "win32":
|
|
817
|
+
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
|
818
|
+
kwargs["stdin"] = subprocess.DEVNULL
|
|
819
|
+
kwargs["stdout"] = subprocess.DEVNULL
|
|
820
|
+
kwargs["stderr"] = subprocess.DEVNULL
|
|
821
|
+
|
|
822
|
+
subprocess.Popen([sys.executable, srv], **kwargs)
|
|
823
|
+
console.print("\n[green]Gateway running in background.[/] Use [bold]synckey status[/] to monitor.")
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
@app.command()
|
|
827
|
+
def usage(
|
|
828
|
+
hours: float = typer.Option(None, "--hours"),
|
|
829
|
+
recent: bool = typer.Option(False, "--recent"),
|
|
830
|
+
spend: bool = typer.Option(False, "--spend", help="Show cost breakdown by provider and model."),
|
|
831
|
+
):
|
|
832
|
+
"""Token usage and request monitoring.
|
|
833
|
+
|
|
834
|
+
Use --spend for cost breakdown, --recent for last 25 requests.
|
|
835
|
+
"""
|
|
836
|
+
ctx = load_ctx()
|
|
837
|
+
since = time.time() - hours * 3600 if hours else None
|
|
838
|
+
totals = ctx.db.usage_totals(since)
|
|
839
|
+
|
|
840
|
+
if spend:
|
|
841
|
+
total_cost = totals["cost_usd"] or 0.0
|
|
842
|
+
console.print(f"[bold]Total spend:[/] [green]{fmt_cost(total_cost)}[/]"
|
|
843
|
+
+ (f" (last {hours}h)" if hours else " (all time)"))
|
|
844
|
+
summary = ctx.db.usage_summary(since)
|
|
845
|
+
if not summary:
|
|
846
|
+
console.print("[dim]No usage data.[/]")
|
|
847
|
+
ctx.close()
|
|
848
|
+
return
|
|
849
|
+
table = Table(title="Spend breakdown")
|
|
850
|
+
for col in ("provider", "model", "tier", "requests", "tokens", "cost", "% total"):
|
|
851
|
+
table.add_column(col)
|
|
852
|
+
for r in summary:
|
|
853
|
+
cost = r["cost_usd"] or 0.0
|
|
854
|
+
pct = (cost / total_cost * 100) if total_cost else 0
|
|
855
|
+
tier_name = TIER_NAMES.get(r["tier"] or 0, "")
|
|
856
|
+
table.add_row(
|
|
857
|
+
r["provider"], r["model"], tier_name,
|
|
858
|
+
str(r["requests"]), fmt_num(r["total_tokens"]),
|
|
859
|
+
fmt_cost(cost), f"{pct:.1f}%",
|
|
860
|
+
)
|
|
861
|
+
console.print(table)
|
|
862
|
+
ctx.close()
|
|
863
|
+
return
|
|
864
|
+
|
|
865
|
+
if recent:
|
|
866
|
+
table = Table(title="Recent requests")
|
|
867
|
+
for col in ("when", "provider", "model", "tier", "tokens", "cost", "status", "ms"):
|
|
868
|
+
table.add_column(col)
|
|
869
|
+
for r in ctx.db.recent_usage(25):
|
|
870
|
+
ago = int(time.time() - r["ts"])
|
|
871
|
+
status = r["status_code"] or 0
|
|
872
|
+
color = "green" if status == 200 else "red"
|
|
873
|
+
tier_name = TIER_NAMES.get(r["tier"] or 0, "")
|
|
874
|
+
table.add_row(
|
|
875
|
+
f"{ago}s ago", r["provider"], r["model"], tier_name,
|
|
876
|
+
fmt_num(r["total_tokens"]), fmt_cost(r["cost_usd"]),
|
|
877
|
+
f"[{color}]{status}[/]", str(r["latency_ms"]),
|
|
878
|
+
)
|
|
879
|
+
console.print(table)
|
|
880
|
+
ctx.close()
|
|
881
|
+
return
|
|
882
|
+
|
|
883
|
+
console.print(
|
|
884
|
+
Panel.fit(
|
|
885
|
+
f"requests: [bold]{totals['requests'] or 0}[/] ([red]{totals['errors'] or 0} errors[/])\n"
|
|
886
|
+
f"tokens: [bold cyan]{fmt_num(totals['total_tokens'])}[/] total "
|
|
887
|
+
f"({fmt_num(totals['prompt_tokens'])} prompt + {fmt_num(totals['completion_tokens'])} completion)\n"
|
|
888
|
+
f"cost: [bold green]{fmt_cost(totals['cost_usd'])}[/]",
|
|
889
|
+
title=f"Usage{' (last {}h)'.format(hours) if hours else ' (all time)'}",
|
|
890
|
+
border_style="cyan",
|
|
891
|
+
)
|
|
892
|
+
)
|
|
893
|
+
summary = ctx.db.usage_summary(since)
|
|
894
|
+
if summary:
|
|
895
|
+
table = Table(title="By model")
|
|
896
|
+
for col in ("provider", "model", "tier", "reqs", "tokens", "cost", "errors", "avg ms"):
|
|
897
|
+
table.add_column(col)
|
|
898
|
+
for r in summary:
|
|
899
|
+
tier_name = TIER_NAMES.get(r["tier"] or 0, "")
|
|
900
|
+
table.add_row(
|
|
901
|
+
r["provider"], r["model"], tier_name,
|
|
902
|
+
str(r["requests"]), fmt_num(r["total_tokens"]),
|
|
903
|
+
fmt_cost(r["cost_usd"]),
|
|
904
|
+
str(r["errors"] or 0),
|
|
905
|
+
f"{r['avg_latency']:.0f}" if r["avg_latency"] else "",
|
|
906
|
+
)
|
|
907
|
+
console.print(table)
|
|
908
|
+
ctx.close()
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
@app.command("spend", hidden=True)
|
|
912
|
+
def spend(hours: float = typer.Option(None, "--hours")):
|
|
913
|
+
"""Cost breakdown by provider and model. (Use: synckey usage --spend)"""
|
|
914
|
+
ctx = load_ctx()
|
|
915
|
+
since = time.time() - hours * 3600 if hours else None
|
|
916
|
+
totals = ctx.db.usage_totals(since)
|
|
917
|
+
summary = ctx.db.usage_summary(since)
|
|
918
|
+
|
|
919
|
+
total_cost = totals["cost_usd"] or 0.0
|
|
920
|
+
console.print(f"[bold]Total spend:[/] [green]{fmt_cost(total_cost)}[/]"
|
|
921
|
+
+ (f" (last {hours}h)" if hours else " (all time)"))
|
|
922
|
+
|
|
923
|
+
if not summary:
|
|
924
|
+
console.print("[dim]No usage data.[/]")
|
|
925
|
+
ctx.close()
|
|
926
|
+
return
|
|
927
|
+
|
|
928
|
+
table = Table(title="Spend breakdown")
|
|
929
|
+
for col in ("provider", "model", "tier", "requests", "tokens", "cost", "% total"):
|
|
930
|
+
table.add_column(col)
|
|
931
|
+
for r in summary:
|
|
932
|
+
cost = r["cost_usd"] or 0.0
|
|
933
|
+
pct = (cost / total_cost * 100) if total_cost else 0
|
|
934
|
+
tier_name = TIER_NAMES.get(r["tier"] or 0, "")
|
|
935
|
+
table.add_row(
|
|
936
|
+
r["provider"], r["model"], tier_name,
|
|
937
|
+
str(r["requests"]), fmt_num(r["total_tokens"]),
|
|
938
|
+
fmt_cost(cost), f"{pct:.1f}%",
|
|
939
|
+
)
|
|
940
|
+
console.print(table)
|
|
941
|
+
ctx.close()
|
|
942
|
+
ctx = load_ctx()
|
|
943
|
+
since = time.time() - hours * 3600 if hours else None
|
|
944
|
+
totals = ctx.db.usage_totals(since)
|
|
945
|
+
summary = ctx.db.usage_summary(since)
|
|
946
|
+
|
|
947
|
+
total_cost = totals["cost_usd"] or 0.0
|
|
948
|
+
console.print(f"[bold]Total spend:[/] [green]{fmt_cost(total_cost)}[/]"
|
|
949
|
+
+ (f" (last {hours}h)" if hours else " (all time)"))
|
|
950
|
+
|
|
951
|
+
if not summary:
|
|
952
|
+
console.print("[dim]No usage data.[/]")
|
|
953
|
+
ctx.close()
|
|
954
|
+
return
|
|
955
|
+
|
|
956
|
+
table = Table(title="Spend breakdown")
|
|
957
|
+
for col in ("provider", "model", "tier", "requests", "tokens", "cost", "% total"):
|
|
958
|
+
table.add_column(col)
|
|
959
|
+
for r in summary:
|
|
960
|
+
cost = r["cost_usd"] or 0.0
|
|
961
|
+
pct = (cost / total_cost * 100) if total_cost else 0
|
|
962
|
+
tier_name = TIER_NAMES.get(r["tier"] or 0, "")
|
|
963
|
+
table.add_row(
|
|
964
|
+
r["provider"], r["model"], tier_name,
|
|
965
|
+
str(r["requests"]), fmt_num(r["total_tokens"]),
|
|
966
|
+
fmt_cost(cost), f"{pct:.1f}%",
|
|
967
|
+
)
|
|
968
|
+
console.print(table)
|
|
969
|
+
ctx.close()
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
@app.command()
|
|
973
|
+
def events(
|
|
974
|
+
type: str = typer.Option(None, "--type", "-t", help="Filter: rate_limited|key_dead|tier_fallback"),
|
|
975
|
+
limit: int = typer.Option(50, "--limit", "-n"),
|
|
976
|
+
):
|
|
977
|
+
"""Routing events: rate limits, key deaths, tier fallbacks."""
|
|
978
|
+
ctx = load_ctx()
|
|
979
|
+
rows = ctx.db.recent_events(limit, event_type=type)
|
|
980
|
+
if not rows:
|
|
981
|
+
console.print("[dim]No events.[/]")
|
|
982
|
+
ctx.close()
|
|
983
|
+
return
|
|
984
|
+
table = Table(title="Events")
|
|
985
|
+
for col in ("when", "type", "provider", "model", "fallback_to", "message"):
|
|
986
|
+
table.add_column(col)
|
|
987
|
+
type_colors = {"rate_limited": "yellow", "key_dead": "red", "tier_fallback": "magenta"}
|
|
988
|
+
for r in rows:
|
|
989
|
+
ago = int(time.time() - r["ts"])
|
|
990
|
+
etype = r["type"] or ""
|
|
991
|
+
color = type_colors.get(etype, "")
|
|
992
|
+
table.add_row(
|
|
993
|
+
f"{ago}s ago",
|
|
994
|
+
f"[{color}]{etype}[/]" if color else etype,
|
|
995
|
+
r["provider"] or "",
|
|
996
|
+
r["model"] or "",
|
|
997
|
+
r["fallback_to"] or "",
|
|
998
|
+
(r["message"] or "")[:80],
|
|
999
|
+
)
|
|
1000
|
+
console.print(table)
|
|
1001
|
+
ctx.close()
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
deferred_app = typer.Typer(help="Inspect the deferred request queue.", no_args_is_help=True)
|
|
1005
|
+
app.add_typer(deferred_app, name="deferred")
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
@deferred_app.command("list")
|
|
1009
|
+
def deferred_list(limit: int = typer.Option(25, "--limit", "-n")):
|
|
1010
|
+
"""Show queued/running/done deferred requests."""
|
|
1011
|
+
ctx = load_ctx()
|
|
1012
|
+
rows = ctx.db.recent_deferred(limit)
|
|
1013
|
+
if not rows:
|
|
1014
|
+
console.print("[dim]No deferred requests.[/]")
|
|
1015
|
+
ctx.close()
|
|
1016
|
+
return
|
|
1017
|
+
colors = {"queued": "yellow", "running": "cyan", "done": "green", "error": "red"}
|
|
1018
|
+
t = Table(title="Deferred queue")
|
|
1019
|
+
for col in ("id", "status", "model", "age", "eta", "code"):
|
|
1020
|
+
t.add_column(col)
|
|
1021
|
+
now = time.time()
|
|
1022
|
+
for r in rows:
|
|
1023
|
+
status_ = r["status"]
|
|
1024
|
+
eta = r["eta"] or 0
|
|
1025
|
+
eta_str = f"{int(eta - now)}s" if status_ in ("queued", "running") and eta > now else ""
|
|
1026
|
+
t.add_row(
|
|
1027
|
+
r["id"][:18],
|
|
1028
|
+
f"[{colors.get(status_, '')}]{status_}[/]" if colors.get(status_) else status_,
|
|
1029
|
+
r["model"] or "",
|
|
1030
|
+
f"{int(now - r['created_at'])}s",
|
|
1031
|
+
eta_str,
|
|
1032
|
+
str(r["status_code"] or ""),
|
|
1033
|
+
)
|
|
1034
|
+
console.print(t)
|
|
1035
|
+
console.print(f"[dim]TTL after completion: {int(ctx.settings.deferred_ttl)}s. Poll: GET /v1/requests/<id>[/]")
|
|
1036
|
+
ctx.close()
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
@deferred_app.command("get")
|
|
1040
|
+
def deferred_get(request_id: str = typer.Argument(...)):
|
|
1041
|
+
"""Show a deferred request's status and stored response (if done)."""
|
|
1042
|
+
ctx = load_ctx()
|
|
1043
|
+
r = ctx.db.get_deferred(request_id)
|
|
1044
|
+
if r is None:
|
|
1045
|
+
err_con.print("[red]Unknown or expired request id.[/]")
|
|
1046
|
+
raise typer.Exit(1)
|
|
1047
|
+
console.print(
|
|
1048
|
+
Panel.fit(
|
|
1049
|
+
f"id: [cyan]{r['id']}[/]\n"
|
|
1050
|
+
f"status: [bold]{r['status']}[/]\n"
|
|
1051
|
+
f"model: {r['model'] or ''}\n"
|
|
1052
|
+
f"code: {r['status_code'] or ''}\n"
|
|
1053
|
+
f"attempts:{r['attempts']}",
|
|
1054
|
+
title="Deferred request",
|
|
1055
|
+
border_style="cyan",
|
|
1056
|
+
)
|
|
1057
|
+
)
|
|
1058
|
+
if r["status"] == "done" and r["response"]:
|
|
1059
|
+
body = r["response"].decode("utf-8", "replace")
|
|
1060
|
+
console.print(body[:2000] + ("..." if len(body) > 2000 else ""))
|
|
1061
|
+
elif r["status"] == "error":
|
|
1062
|
+
console.print(f"[red]{r['error'] or ''}[/]")
|
|
1063
|
+
ctx.close()
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
@app.command()
|
|
1067
|
+
def status():
|
|
1068
|
+
"""At-a-glance overview."""
|
|
1069
|
+
ctx = load_ctx()
|
|
1070
|
+
initialized = bool(ctx.unified_key_hash())
|
|
1071
|
+
keys = ctx.db.list_keys()
|
|
1072
|
+
configured = ctx.db.providers_with_keys()
|
|
1073
|
+
totals = ctx.db.usage_totals()
|
|
1074
|
+
|
|
1075
|
+
all_states = ctx.states.all()
|
|
1076
|
+
from .state import Health as H
|
|
1077
|
+
n_live = sum(1 for s in all_states.values() if s.health == H.LIVE)
|
|
1078
|
+
n_cool = sum(1 for s in all_states.values() if s.health == H.COOLING)
|
|
1079
|
+
n_dead = sum(1 for s in all_states.values() if s.health == H.DEAD)
|
|
1080
|
+
|
|
1081
|
+
console.print(
|
|
1082
|
+
Panel.fit(
|
|
1083
|
+
f"home: [cyan]{home()}[/]\n"
|
|
1084
|
+
f"initialized: {'[green]yes[/]' if initialized else '[red]no, run synckey setup[/]'}\n"
|
|
1085
|
+
f"providers: {len(configured)} ({', '.join(configured) or 'none'})\n"
|
|
1086
|
+
f"keys: {len(keys)} stored [green]{n_live} live[/] [yellow]{n_cool} cooling[/] [red]{n_dead} dead[/]\n"
|
|
1087
|
+
f"models known: {len(ctx.router.index)}\n"
|
|
1088
|
+
f"default model:{(' ' + ctx.prefs.global_default()) if ctx.prefs.global_default() else ' (none)'}\n"
|
|
1089
|
+
f"lifetime: {totals['requests'] or 0} requests, "
|
|
1090
|
+
f"{fmt_num(totals['total_tokens'])} tokens, {fmt_cost(totals['cost_usd'])}\n"
|
|
1091
|
+
f"gateway: http://{ctx.settings.host}:{ctx.settings.port}/v1\n"
|
|
1092
|
+
f"quality floor: auto (inferred from model tier) + X-Quality-Floor header",
|
|
1093
|
+
title=f"synckey {__version__}",
|
|
1094
|
+
border_style="magenta",
|
|
1095
|
+
)
|
|
1096
|
+
)
|
|
1097
|
+
ctx.close()
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
@app.command()
|
|
1101
|
+
def test(
|
|
1102
|
+
provider: str = typer.Argument(None, help="Provider id (omit to health-check all)."),
|
|
1103
|
+
model: str = typer.Argument(None, help="Model id (with provider: makes actual test calls)."),
|
|
1104
|
+
calls: int = typer.Argument(1, help="Number of test calls to make."),
|
|
1105
|
+
):
|
|
1106
|
+
"""Health-check stored keys, or test a route with actual calls.
|
|
1107
|
+
|
|
1108
|
+
Without model: hits each provider's /models endpoint (health check).
|
|
1109
|
+
|
|
1110
|
+
With provider + model: makes `calls` actual API calls to that provider/model
|
|
1111
|
+
and shows per-call routing, latency, tokens, and status.
|
|
1112
|
+
"""
|
|
1113
|
+
import httpx
|
|
1114
|
+
|
|
1115
|
+
ctx = load_ctx()
|
|
1116
|
+
|
|
1117
|
+
if model:
|
|
1118
|
+
_run_test_calls(ctx, provider, model, calls)
|
|
1119
|
+
ctx.close()
|
|
1120
|
+
return
|
|
1121
|
+
|
|
1122
|
+
# Health-check mode (original behavior)
|
|
1123
|
+
targets = [provider.lower()] if provider else ctx.db.providers_with_keys()
|
|
1124
|
+
if not targets:
|
|
1125
|
+
console.print("[yellow]No providers to test.[/]")
|
|
1126
|
+
ctx.close()
|
|
1127
|
+
return
|
|
1128
|
+
|
|
1129
|
+
table = Table(title="Key health check")
|
|
1130
|
+
for col in ("provider", "key", "current health", "api result"):
|
|
1131
|
+
table.add_column(col)
|
|
1132
|
+
|
|
1133
|
+
all_states = ctx.states.all()
|
|
1134
|
+
for pid in targets:
|
|
1135
|
+
prov = ctx.providers.get(pid)
|
|
1136
|
+
if not prov:
|
|
1137
|
+
table.add_row(pid, "", "", "[red]unknown provider[/]")
|
|
1138
|
+
continue
|
|
1139
|
+
url = prov.base_url.rstrip("/") + prov.models_path
|
|
1140
|
+
for k in ctx.db.list_keys(provider=pid, enabled_only=True):
|
|
1141
|
+
st = all_states.get(k.id)
|
|
1142
|
+
from .state import KeyState
|
|
1143
|
+
ks = st if st else KeyState()
|
|
1144
|
+
cur = health_text(ks.health, ks.cooldown_remaining())
|
|
1145
|
+
try:
|
|
1146
|
+
secret = ctx.box.open(k.secret)
|
|
1147
|
+
except RuntimeError:
|
|
1148
|
+
table.add_row(pid, f"#{k.id} {k.label}", cur, "[red]key corrupt re-add with synckey setup[/]")
|
|
1149
|
+
continue
|
|
1150
|
+
try:
|
|
1151
|
+
resp = httpx.get(
|
|
1152
|
+
url,
|
|
1153
|
+
headers=prov.auth_headers(secret),
|
|
1154
|
+
params=prov.auth_params(secret),
|
|
1155
|
+
timeout=20.0,
|
|
1156
|
+
)
|
|
1157
|
+
if resp.status_code == 200:
|
|
1158
|
+
result = "[green]ok[/]"
|
|
1159
|
+
elif resp.status_code in (401, 403):
|
|
1160
|
+
result = "[red]auth failed (key dead/expired)[/]"
|
|
1161
|
+
ctx.pool.on_dead(k.id, f"HTTP {resp.status_code} during health check")
|
|
1162
|
+
elif resp.status_code == 429:
|
|
1163
|
+
result = "[yellow]rate limited[/]"
|
|
1164
|
+
else:
|
|
1165
|
+
result = f"[red]HTTP {resp.status_code}[/]"
|
|
1166
|
+
except httpx.HTTPError as exc:
|
|
1167
|
+
result = f"[red]{type(exc).__name__}[/]"
|
|
1168
|
+
table.add_row(pid, f"#{k.id} {k.label}", cur, result)
|
|
1169
|
+
console.print(table)
|
|
1170
|
+
ctx.close()
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
async def _call_model(ctx, provider, model, k, secret):
|
|
1174
|
+
"""Make a single test call; returns (status_code, latency_ms, tokens, error, chain)."""
|
|
1175
|
+
import httpx
|
|
1176
|
+
prov = ctx.providers.get(provider)
|
|
1177
|
+
if not prov:
|
|
1178
|
+
return 0, 0, 0, f"unknown provider {provider}", ""
|
|
1179
|
+
|
|
1180
|
+
body = {
|
|
1181
|
+
"model": model,
|
|
1182
|
+
"messages": [{"role": "user", "content": "say hi in one word"}],
|
|
1183
|
+
"max_tokens": 10,
|
|
1184
|
+
}
|
|
1185
|
+
chain_parts = [f"{provider}/key#{k.id}"]
|
|
1186
|
+
try:
|
|
1187
|
+
url = prov.base_url.rstrip("/") + "/chat/completions"
|
|
1188
|
+
t0 = time.time()
|
|
1189
|
+
resp = httpx.AsyncClient(timeout=30.0).post(
|
|
1190
|
+
url,
|
|
1191
|
+
headers={**prov.auth_headers(secret), "Content-Type": "application/json"},
|
|
1192
|
+
json=body,
|
|
1193
|
+
)
|
|
1194
|
+
resp = await resp
|
|
1195
|
+
latency = int((time.time() - t0) * 1000)
|
|
1196
|
+
if resp.status_code == 200:
|
|
1197
|
+
data = resp.json()
|
|
1198
|
+
tokens = (data.get("usage") or {}).get("total_tokens", 0)
|
|
1199
|
+
return resp.status_code, latency, tokens, None, " > ".join(chain_parts)
|
|
1200
|
+
else:
|
|
1201
|
+
err_msg = f"HTTP {resp.status_code}"
|
|
1202
|
+
try:
|
|
1203
|
+
err_msg = resp.json().get("error", {}).get("message", err_msg)
|
|
1204
|
+
except Exception:
|
|
1205
|
+
pass
|
|
1206
|
+
return resp.status_code, latency, 0, err_msg, " > ".join(chain_parts)
|
|
1207
|
+
except Exception as exc:
|
|
1208
|
+
return 0, 0, 0, str(exc), " > ".join(chain_parts)
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _run_test_calls(ctx, provider, model, calls):
|
|
1212
|
+
"""Run `calls` actual test calls to the given provider/model."""
|
|
1213
|
+
if provider.lower() not in ctx.providers:
|
|
1214
|
+
err_con.print(f"[red]Unknown provider:[/] {provider}")
|
|
1215
|
+
err_con.print(f"[dim]Run `synckey providers` for the list.[/]")
|
|
1216
|
+
return
|
|
1217
|
+
|
|
1218
|
+
keys = ctx.db.list_keys(provider=provider.lower(), enabled_only=True)
|
|
1219
|
+
if not keys:
|
|
1220
|
+
err_con.print(f"[red]No enabled keys for[/] {provider}.")
|
|
1221
|
+
err_con.print(f"[dim]Run `synckey setup` first.[/]")
|
|
1222
|
+
return
|
|
1223
|
+
|
|
1224
|
+
console.print(f"[cyan]Testing[/] {provider}/{model} {calls} call(s) with {len(keys)} key(s)\n")
|
|
1225
|
+
|
|
1226
|
+
table = Table(title=f"Route test: {provider}/{model}")
|
|
1227
|
+
table.add_column("#", justify="right")
|
|
1228
|
+
table.add_column("key")
|
|
1229
|
+
table.add_column("provider", style="cyan")
|
|
1230
|
+
table.add_column("model")
|
|
1231
|
+
table.add_column("status", style="bold")
|
|
1232
|
+
table.add_column("ms", justify="right")
|
|
1233
|
+
table.add_column("tokens", justify="right")
|
|
1234
|
+
table.add_column("error / chain")
|
|
1235
|
+
|
|
1236
|
+
import asyncio
|
|
1237
|
+
results = []
|
|
1238
|
+
for i in range(calls):
|
|
1239
|
+
for k in keys:
|
|
1240
|
+
try:
|
|
1241
|
+
secret = ctx.box.open(k.secret)
|
|
1242
|
+
except RuntimeError:
|
|
1243
|
+
err_con.print(f"[red]Key #{k.id} is corrupt (secret.key may have changed).[/]")
|
|
1244
|
+
err_con.print(f"[dim]Re-add it: synckey setup[/]")
|
|
1245
|
+
continue
|
|
1246
|
+
status, latency, tokens, error, chain = asyncio.run(
|
|
1247
|
+
_call_model(ctx, provider.lower(), model, k, secret)
|
|
1248
|
+
)
|
|
1249
|
+
results.append((i + 1, k, provider, model, status, latency, tokens, error, chain))
|
|
1250
|
+
|
|
1251
|
+
for i, k, prov, mod, status, latency, tokens, error, chain in results:
|
|
1252
|
+
status_str = f"[green]{status}[/]" if status == 200 else f"[red]{status}[/]"
|
|
1253
|
+
err_str = f"[red]{error}[/]" if error else chain
|
|
1254
|
+
table.add_row(
|
|
1255
|
+
str(i), f"#{k.id}", prov, mod,
|
|
1256
|
+
status_str, str(latency), str(tokens) if tokens else "—", err_str,
|
|
1257
|
+
)
|
|
1258
|
+
console.print(table)
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _read_key(timeout: float) -> str | None:
|
|
1262
|
+
"""Poll for a single keypress for up to `timeout` seconds. Windows: msvcrt.
|
|
1263
|
+
|
|
1264
|
+
Returns the lowercased character, or None if nothing was pressed (or the
|
|
1265
|
+
platform has no non-blocking console read then it just sleeps).
|
|
1266
|
+
"""
|
|
1267
|
+
try:
|
|
1268
|
+
import msvcrt # Windows only
|
|
1269
|
+
except ImportError:
|
|
1270
|
+
time.sleep(timeout)
|
|
1271
|
+
return None
|
|
1272
|
+
end = time.time() + timeout
|
|
1273
|
+
while time.time() < end:
|
|
1274
|
+
if msvcrt.kbhit():
|
|
1275
|
+
try:
|
|
1276
|
+
ch = msvcrt.getwch()
|
|
1277
|
+
except Exception:
|
|
1278
|
+
return None
|
|
1279
|
+
return ch.lower()
|
|
1280
|
+
time.sleep(0.05)
|
|
1281
|
+
return None
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
@app.command()
|
|
1285
|
+
def dash(interval: float = typer.Option(2.0, "--interval", "-i", help="Refresh interval (seconds).")):
|
|
1286
|
+
"""Live dashboard with keyboard navigation.
|
|
1287
|
+
|
|
1288
|
+
Keys: [1] overview [2] keys [3] events [4] routing [p] pause [r] refresh [q] quit.
|
|
1289
|
+
"""
|
|
1290
|
+
from .state import Health as H, KeyState
|
|
1291
|
+
|
|
1292
|
+
ctx = load_ctx()
|
|
1293
|
+
interactive = sys.stdin.isatty()
|
|
1294
|
+
|
|
1295
|
+
def _header(view: str, paused: bool) -> Panel:
|
|
1296
|
+
totals = ctx.db.usage_totals()
|
|
1297
|
+
all_st = ctx.states.all()
|
|
1298
|
+
n_live = sum(1 for s in all_st.values() if s.health == H.LIVE)
|
|
1299
|
+
n_cool = sum(1 for s in all_st.values() if s.health == H.COOLING)
|
|
1300
|
+
n_dead = sum(1 for s in all_st.values() if s.health == H.DEAD)
|
|
1301
|
+
default = ctx.prefs.global_default() or "(none)"
|
|
1302
|
+
state = "[yellow]PAUSED[/]" if paused else "[green]live[/]"
|
|
1303
|
+
return Panel(
|
|
1304
|
+
f"[bold]{totals['requests'] or 0}[/] requests "
|
|
1305
|
+
f"[cyan]{fmt_num(totals['total_tokens'])}[/] tokens "
|
|
1306
|
+
f"[green]{fmt_cost(totals['cost_usd'])}[/] spent "
|
|
1307
|
+
f"keys: [green]{n_live} live[/] [yellow]{n_cool} cooling[/] [red]{n_dead} dead[/] "
|
|
1308
|
+
f"default: [cyan]{default}[/]",
|
|
1309
|
+
title=f"[bold]synckey {__version__}[/] · view: [bold]{view}[/] · {state}",
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
def _legend() -> Panel:
|
|
1313
|
+
hint = (
|
|
1314
|
+
"[bold]1[/] overview [bold]2[/] keys [bold]3[/] events "
|
|
1315
|
+
"[bold]4[/] routing [bold]p[/] pause [bold]r[/] refresh [bold]q[/] quit"
|
|
1316
|
+
if interactive
|
|
1317
|
+
else "Ctrl+C to exit · (interactive keys unavailable in this terminal)"
|
|
1318
|
+
)
|
|
1319
|
+
return Panel(hint, style="dim")
|
|
1320
|
+
|
|
1321
|
+
def _keys_panel() -> Panel:
|
|
1322
|
+
keys = ctx.db.list_keys()
|
|
1323
|
+
stats = ctx.db.key_stats()
|
|
1324
|
+
all_st = ctx.states.all()
|
|
1325
|
+
t = Table(show_header=True, header_style="bold", expand=True, box=None)
|
|
1326
|
+
for col in ("#", "provider", "health", "rpm", "reqs", "cost"):
|
|
1327
|
+
t.add_column(col)
|
|
1328
|
+
for k in keys:
|
|
1329
|
+
st = all_st.get(k.id)
|
|
1330
|
+
ks = st if st else KeyState()
|
|
1331
|
+
remaining = ks.cooldown_remaining()
|
|
1332
|
+
ht = health_text(ks.health, remaining)
|
|
1333
|
+
bucket = ctx.pool._buckets._buckets.get(k.id)
|
|
1334
|
+
rpm_used = f"{bucket.emission_rpm():.1f}" if bucket else "0.0"
|
|
1335
|
+
s = stats.get(k.id, {})
|
|
1336
|
+
t.add_row(str(k.id), k.provider, ht, rpm_used, str(s.get("requests", 0)), fmt_cost(s.get("cost")))
|
|
1337
|
+
return Panel(t, title="Keys")
|
|
1338
|
+
|
|
1339
|
+
def _events_panel() -> Panel:
|
|
1340
|
+
rows = ctx.db.recent_events(10)
|
|
1341
|
+
t = Table(show_header=True, header_style="bold", expand=True, box=None)
|
|
1342
|
+
for col in ("when", "type", "model", "note"):
|
|
1343
|
+
t.add_column(col)
|
|
1344
|
+
colors = {"rate_limited": "yellow", "key_dead": "red", "tier_fallback": "magenta"}
|
|
1345
|
+
for r in rows:
|
|
1346
|
+
ago = int(time.time() - r["ts"])
|
|
1347
|
+
etype = r["type"] or ""
|
|
1348
|
+
color = colors.get(etype, "")
|
|
1349
|
+
t.add_row(
|
|
1350
|
+
f"{ago}s",
|
|
1351
|
+
f"[{color}]{etype}[/]" if color else etype,
|
|
1352
|
+
r["model"] or "",
|
|
1353
|
+
(r["message"] or "")[:40],
|
|
1354
|
+
)
|
|
1355
|
+
return Panel(t, title="Events")
|
|
1356
|
+
|
|
1357
|
+
def _requests_panel() -> Panel:
|
|
1358
|
+
t = Table(show_header=True, header_style="bold", expand=True, box=None)
|
|
1359
|
+
for col in ("when", "provider", "model", "status", "tokens", "ms"):
|
|
1360
|
+
t.add_column(col)
|
|
1361
|
+
for r in ctx.db.recent_usage(6):
|
|
1362
|
+
ago = int(time.time() - r["ts"])
|
|
1363
|
+
status = r["status_code"] or 0
|
|
1364
|
+
color = "green" if status == 200 else "red"
|
|
1365
|
+
t.add_row(
|
|
1366
|
+
f"{ago}s", r["provider"], r["model"],
|
|
1367
|
+
f"[{color}]{status}[/]", fmt_num(r["total_tokens"]), str(r["latency_ms"] or 0),
|
|
1368
|
+
)
|
|
1369
|
+
return Panel(t, title="Recent requests")
|
|
1370
|
+
|
|
1371
|
+
def _routing_panel() -> Panel:
|
|
1372
|
+
chains = ctx.db.recent_routing_chains(15)
|
|
1373
|
+
if not chains:
|
|
1374
|
+
return Panel(
|
|
1375
|
+
"[dim]No routing chain data yet.\n"
|
|
1376
|
+
"Send requests through the gateway to see the routing path here.\n"
|
|
1377
|
+
"Or run: synckey test groq llama-3.3-70b-versatile 5[/]",
|
|
1378
|
+
title="Routing chain",
|
|
1379
|
+
border_style="cyan",
|
|
1380
|
+
)
|
|
1381
|
+
t = Table(show_header=True, header_style="bold", expand=True, box=None)
|
|
1382
|
+
for col in ("when", "model", "provider", "key", "chain"):
|
|
1383
|
+
t.add_column(col)
|
|
1384
|
+
for r in chains:
|
|
1385
|
+
ago = int(time.time() - r["ts"])
|
|
1386
|
+
chain_text = r["chain"] or ""
|
|
1387
|
+
t.add_row(
|
|
1388
|
+
f"{ago}s",
|
|
1389
|
+
r["model"] or "",
|
|
1390
|
+
r["provider"] or "",
|
|
1391
|
+
f"#{r['key_id']}" if r["key_id"] else "",
|
|
1392
|
+
chain_text[:80] + ("…" if len(chain_text) > 80 else ""),
|
|
1393
|
+
)
|
|
1394
|
+
return Panel(t, title="Routing chain (recent)")
|
|
1395
|
+
|
|
1396
|
+
def _build(view: str, paused: bool) -> Layout:
|
|
1397
|
+
layout = Layout()
|
|
1398
|
+
layout.split_column(
|
|
1399
|
+
Layout(name="header", size=3),
|
|
1400
|
+
Layout(name="body"),
|
|
1401
|
+
Layout(name="legend", size=3),
|
|
1402
|
+
)
|
|
1403
|
+
layout["header"].update(_header(view, paused))
|
|
1404
|
+
layout["legend"].update(_legend())
|
|
1405
|
+
if view == "keys":
|
|
1406
|
+
layout["body"].split_column(
|
|
1407
|
+
Layout(_keys_panel(), name="keys", ratio=2),
|
|
1408
|
+
Layout(_requests_panel(), name="requests", ratio=1),
|
|
1409
|
+
)
|
|
1410
|
+
elif view == "events":
|
|
1411
|
+
layout["body"].update(_events_panel())
|
|
1412
|
+
elif view == "routing":
|
|
1413
|
+
layout["body"].update(_routing_panel())
|
|
1414
|
+
else: # overview
|
|
1415
|
+
layout["body"].split_column(Layout(name="top"), Layout(name="bottom", size=9))
|
|
1416
|
+
layout["body"]["top"].split_row(
|
|
1417
|
+
Layout(_keys_panel(), name="keys", ratio=3),
|
|
1418
|
+
Layout(_events_panel(), name="events", ratio=2),
|
|
1419
|
+
)
|
|
1420
|
+
layout["body"]["bottom"].update(_requests_panel())
|
|
1421
|
+
return layout
|
|
1422
|
+
|
|
1423
|
+
view = "overview"
|
|
1424
|
+
paused = False
|
|
1425
|
+
views = {"1": "overview", "2": "keys", "3": "events", "4": "routing"}
|
|
1426
|
+
last = 0.0
|
|
1427
|
+
try:
|
|
1428
|
+
with Live(_build(view, paused), refresh_per_second=4, screen=True) as live:
|
|
1429
|
+
while True:
|
|
1430
|
+
now = time.time()
|
|
1431
|
+
if not paused and now - last >= interval:
|
|
1432
|
+
live.update(_build(view, paused))
|
|
1433
|
+
last = now
|
|
1434
|
+
ch = _read_key(0.2)
|
|
1435
|
+
if ch is None:
|
|
1436
|
+
continue
|
|
1437
|
+
if ch in ("q", "\x1b"): # q or ESC
|
|
1438
|
+
break
|
|
1439
|
+
elif ch == "p":
|
|
1440
|
+
paused = not paused
|
|
1441
|
+
live.update(_build(view, paused))
|
|
1442
|
+
elif ch == "r":
|
|
1443
|
+
live.update(_build(view, paused))
|
|
1444
|
+
last = now
|
|
1445
|
+
elif ch in views:
|
|
1446
|
+
view = views[ch]
|
|
1447
|
+
live.update(_build(view, paused))
|
|
1448
|
+
except KeyboardInterrupt:
|
|
1449
|
+
pass
|
|
1450
|
+
finally:
|
|
1451
|
+
ctx.close()
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
@app.command()
|
|
1455
|
+
def renew():
|
|
1456
|
+
"""Regenerate your unified API key (old key becomes invalid)."""
|
|
1457
|
+
ensure_home()
|
|
1458
|
+
ctx = Context()
|
|
1459
|
+
unified = generate_unified_key()
|
|
1460
|
+
ctx.db.set_meta("unified_key_hash", sha256(unified))
|
|
1461
|
+
ctx.db.set_meta("created_at", str(time.time()))
|
|
1462
|
+
_ = ctx.box
|
|
1463
|
+
console.print(
|
|
1464
|
+
Panel.fit(
|
|
1465
|
+
f"Your new unified API key (old key is now invalid):\n\n [bold cyan]{unified}[/]\n\n"
|
|
1466
|
+
f"Update your clients to use the new key.",
|
|
1467
|
+
title="Key regenerated",
|
|
1468
|
+
border_style="green",
|
|
1469
|
+
)
|
|
1470
|
+
)
|
|
1471
|
+
ctx.close()
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
@app.command()
|
|
1475
|
+
def version():
|
|
1476
|
+
"""Print the synckey version."""
|
|
1477
|
+
console.print(f"synckey {__version__}")
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
if __name__ == "__main__":
|
|
1481
|
+
app()
|