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