quicksilverpro 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.
@@ -0,0 +1,6 @@
1
+ """QuickSilver Pro command-line interface.
2
+
3
+ See https://quicksilverpro.io for docs. `qsp --help` for quick reference.
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow `python -m quicksilverpro ...` as an alias for the `qsp` entrypoint."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
quicksilverpro/cli.py ADDED
@@ -0,0 +1,599 @@
1
+ """QuickSilver Pro CLI — single-file implementation.
2
+
3
+ Design goals:
4
+ 1. Drop-in OpenAI-compatible thin wrapper: `qsp chat "..."` works immediately.
5
+ 2. AI-agent friendly: every command accepts `--json` and emits structured
6
+ output on stdout with errors on stderr and non-zero exit codes on failure.
7
+ 3. No config surprises: API key lives in `~/.config/quicksilverpro/config.json`
8
+ (or `$QSP_CONFIG_DIR`); the `QSP_API_KEY` env var overrides.
9
+
10
+ The CLI talks to two endpoints:
11
+ - https://api.quicksilverpro.io/v1 — OpenAI-shaped inference
12
+ - https://pay.quicksilverpro.io/v1 — account + usage + keys management
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import sys
20
+ import time
21
+ import webbrowser
22
+ from pathlib import Path
23
+
24
+ import click
25
+ import httpx
26
+ from rich.console import Console
27
+ from rich.table import Table
28
+
29
+ from . import __version__
30
+
31
+ # ────────────────────────── constants ──────────────────────────
32
+
33
+ DEFAULT_API_URL = os.environ.get("QSP_API_URL", "https://api.quicksilverpro.io/v1")
34
+ DEFAULT_AUTH_URL = os.environ.get("QSP_AUTH_URL", "https://pay.quicksilverpro.io")
35
+ DEFAULT_APP_URL = os.environ.get("QSP_APP_URL", "https://quicksilverpro.io")
36
+ DEFAULT_MODEL = os.environ.get("QSP_MODEL", "deepseek-v3")
37
+ HTTP_TIMEOUT = float(os.environ.get("QSP_HTTP_TIMEOUT", "60"))
38
+
39
+ CONFIG_DIR = Path(os.environ.get("QSP_CONFIG_DIR",
40
+ Path.home() / ".config" / "quicksilverpro"))
41
+ CONFIG_PATH = CONFIG_DIR / "config.json"
42
+
43
+ # stderr for humans, stdout reserved for parseable data when `--json` is used.
44
+ _err = Console(stderr=True)
45
+ _out = Console()
46
+
47
+
48
+ # ────────────────────────── config + auth ──────────────────────────
49
+
50
+ def _load_config() -> dict:
51
+ if not CONFIG_PATH.exists():
52
+ return {}
53
+ try:
54
+ return json.loads(CONFIG_PATH.read_text())
55
+ except Exception:
56
+ return {}
57
+
58
+
59
+ def _save_config(cfg: dict) -> None:
60
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
61
+ CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
62
+ try:
63
+ os.chmod(CONFIG_PATH, 0o600) # key is sensitive — best-effort on POSIX.
64
+ except Exception:
65
+ pass
66
+
67
+
68
+ def _resolve_api_key() -> str | None:
69
+ """QSP_API_KEY env var wins; else fall back to ~/.config store."""
70
+ env = os.environ.get("QSP_API_KEY") or os.environ.get("OPENAI_API_KEY_QSP")
71
+ if env:
72
+ return env.strip()
73
+ return (_load_config().get("api_key") or "").strip() or None
74
+
75
+
76
+ def _require_key() -> str:
77
+ k = _resolve_api_key()
78
+ if not k:
79
+ _err.print(
80
+ "[red]Not signed in.[/red] Run [bold]qsp init[/bold] to get started, "
81
+ "or set [bold]QSP_API_KEY[/bold] in your environment."
82
+ )
83
+ sys.exit(2)
84
+ return k
85
+
86
+
87
+ # ────────────────────────── http helpers ──────────────────────────
88
+
89
+ def _auth_client(key: str | None = None) -> httpx.Client:
90
+ headers = {"User-Agent": f"quicksilverpro-cli/{__version__}"}
91
+ if key:
92
+ headers["Authorization"] = f"Bearer {key}"
93
+ return httpx.Client(
94
+ base_url=DEFAULT_AUTH_URL,
95
+ timeout=HTTP_TIMEOUT,
96
+ headers=headers,
97
+ follow_redirects=False,
98
+ )
99
+
100
+
101
+ def _api_client(key: str) -> httpx.Client:
102
+ return httpx.Client(
103
+ base_url=DEFAULT_API_URL,
104
+ timeout=HTTP_TIMEOUT,
105
+ headers={
106
+ "Authorization": f"Bearer {key}",
107
+ "User-Agent": f"quicksilverpro-cli/{__version__}",
108
+ "Content-Type": "application/json",
109
+ },
110
+ follow_redirects=False,
111
+ )
112
+
113
+
114
+ def _extract_error(resp: httpx.Response) -> str:
115
+ try:
116
+ d = resp.json()
117
+ if isinstance(d, dict):
118
+ err = d.get("error")
119
+ if isinstance(err, dict):
120
+ return err.get("message") or str(err)
121
+ if isinstance(d.get("detail"), str):
122
+ return d["detail"]
123
+ return json.dumps(d)
124
+ return resp.text
125
+ except Exception:
126
+ return resp.text or f"HTTP {resp.status_code}"
127
+
128
+
129
+ def _emit(obj, *, as_json: bool, table_fn=None) -> None:
130
+ """JSON goes to stdout; pretty tables go through Rich to stdout."""
131
+ if as_json:
132
+ click.echo(json.dumps(obj, indent=2, default=str))
133
+ return
134
+ if table_fn:
135
+ table_fn(obj)
136
+ else:
137
+ click.echo(json.dumps(obj, indent=2, default=str))
138
+
139
+
140
+ # ────────────────────────── root command group ──────────────────────────
141
+
142
+ @click.group(
143
+ help=(
144
+ "QuickSilver Pro — OpenAI-compatible API for top open-source LLMs.\n\n"
145
+ "Docs: https://quicksilverpro.io · Status: https://quicksilverpro.io/status"
146
+ ),
147
+ context_settings={"help_option_names": ["-h", "--help"]},
148
+ )
149
+ @click.version_option(__version__, prog_name="qsp")
150
+ def main() -> None:
151
+ pass
152
+
153
+
154
+ # ────────────────────────── qsp init ──────────────────────────
155
+
156
+ @main.command(help="Sign in: opens the dashboard in a browser, you paste the API key back.")
157
+ @click.option("--key", "apikey", default=None, help="Paste an existing API key instead of opening the browser.")
158
+ @click.option("--email", default=None, help="Email for a new account (sends verification link).")
159
+ def init(apikey: str | None, email: str | None) -> None:
160
+ # Three paths: explicit --key, new-account via --email, or default browser walk-through.
161
+ if apikey:
162
+ _save_key(apikey)
163
+ return
164
+
165
+ if email:
166
+ with _auth_client() as c:
167
+ r = c.post("/v1/register", json={"email": email})
168
+ if r.status_code >= 400:
169
+ _err.print(f"[red]Register failed:[/red] {_extract_error(r)}")
170
+ sys.exit(1)
171
+ data = r.json()
172
+ if data.get("verification_required"):
173
+ _out.print(f"✓ Verification email sent to [bold]{email}[/bold]. Click the magic link, "
174
+ f"copy the key from the dashboard, then run [bold]qsp init --key sk-...[/bold]")
175
+ return
176
+ # Legacy path (email not configured) — returns key immediately.
177
+ if k := data.get("key"):
178
+ _save_key(k)
179
+ return
180
+ _err.print("[red]Unexpected register response shape.[/red]")
181
+ sys.exit(1)
182
+
183
+ # Default walkthrough: open the dashboard and guide copy-paste.
184
+ _out.print("Opening [bold]quicksilverpro.io/dashboard[/bold] — sign up or sign in, copy your API key, then paste below.")
185
+ try:
186
+ webbrowser.open(f"{DEFAULT_APP_URL}/dashboard")
187
+ except Exception:
188
+ pass
189
+ pasted = click.prompt("Paste API key", hide_input=True, default="", show_default=False).strip()
190
+ if not pasted:
191
+ _err.print("[red]No key entered — aborted.[/red]")
192
+ sys.exit(1)
193
+ _save_key(pasted)
194
+
195
+
196
+ def _save_key(apikey: str) -> None:
197
+ if not apikey.startswith("sk-"):
198
+ _err.print("[red]That doesn't look like an API key (must start with sk-).[/red]")
199
+ sys.exit(1)
200
+ # Verify by calling /v1/me — catches typos + dead keys up-front.
201
+ with _auth_client(apikey) as c:
202
+ r = c.get("/v1/me")
203
+ if r.status_code >= 400:
204
+ _err.print(f"[red]Key rejected by server:[/red] {_extract_error(r)}")
205
+ sys.exit(1)
206
+ info = r.json()
207
+ cfg = _load_config()
208
+ cfg["api_key"] = apikey
209
+ cfg["email"] = info.get("email") or cfg.get("email", "")
210
+ _save_config(cfg)
211
+ _out.print(f"✓ Signed in as [bold]{info.get('email') or 'unknown'}[/bold]. Key stored at {CONFIG_PATH}.")
212
+
213
+
214
+ # ────────────────────────── qsp logout / whoami ──────────────────────────
215
+
216
+ @main.command(help="Forget the locally-stored API key.")
217
+ def logout() -> None:
218
+ if CONFIG_PATH.exists():
219
+ CONFIG_PATH.unlink()
220
+ _out.print("✓ Signed out.")
221
+
222
+
223
+ @main.command(help="Show the email the CLI is signed in as.")
224
+ @click.option("--json", "as_json", is_flag=True)
225
+ def whoami(as_json: bool) -> None:
226
+ key = _require_key()
227
+ with _auth_client(key) as c:
228
+ r = c.get("/v1/me")
229
+ if r.status_code >= 400:
230
+ _err.print(f"[red]{_extract_error(r)}[/red]")
231
+ sys.exit(1)
232
+ info = r.json()
233
+ info["api_base"] = DEFAULT_API_URL
234
+ _emit(info, as_json=as_json, table_fn=_print_whoami)
235
+
236
+
237
+ def _print_whoami(info: dict) -> None:
238
+ _out.print(f"Email: [bold]{info.get('email','—')}[/bold]")
239
+ _out.print(f"Balance: [bold]${max(0, (info.get('max_budget') or 0) - (info.get('spend') or 0)):.4f}[/bold]")
240
+ _out.print(f"Total spent: ${info.get('spend') or 0:.4f}")
241
+ _out.print(f"API base: {info.get('api_base')}")
242
+
243
+
244
+ # ────────────────────────── qsp balance ──────────────────────────
245
+
246
+ @main.command(help="Show current account balance and spend.")
247
+ @click.option("--json", "as_json", is_flag=True)
248
+ def balance(as_json: bool) -> None:
249
+ key = _require_key()
250
+ with _auth_client(key) as c:
251
+ r = c.get("/v1/me")
252
+ if r.status_code >= 400:
253
+ _err.print(f"[red]{_extract_error(r)}[/red]")
254
+ sys.exit(1)
255
+ info = r.json()
256
+ bal = max(0, (info.get("max_budget") or 0) - (info.get("spend") or 0))
257
+ payload = {
258
+ "balance": round(bal, 6),
259
+ "loaded": info.get("max_budget") or 0,
260
+ "spent": info.get("spend") or 0,
261
+ "currency": "USD",
262
+ }
263
+ _emit(payload, as_json=as_json, table_fn=lambda p:
264
+ _out.print(f"Balance: [bold]${p['balance']:.4f}[/bold] of ${p['loaded']:.2f} loaded · spent ${p['spent']:.4f}"))
265
+
266
+
267
+ # ────────────────────────── qsp models ──────────────────────────
268
+
269
+ # Hardcoded so `qsp models` works before the user has signed in. Keep in sync
270
+ # with the backend; tests in CI should catch drift.
271
+ _MODELS_FALLBACK: list[dict] = [
272
+ {"id": "deepseek-v3", "object": "model", "owned_by": "quicksilverpro",
273
+ "context_length": 131072,
274
+ "pricing": {"prompt": "0.0000002400", "completion": "0.0000007000"},
275
+ "best_for": "chat, coding, structured output"},
276
+ {"id": "deepseek-r1", "object": "model", "owned_by": "quicksilverpro",
277
+ "context_length": 131072,
278
+ "pricing": {"prompt": "0.0000004000", "completion": "0.0000017000"},
279
+ "best_for": "math, multi-step reasoning, logic"},
280
+ {"id": "qwen3.5-35b", "object": "model", "owned_by": "quicksilverpro",
281
+ "context_length": 262144,
282
+ "pricing": {"prompt": "0.0000001300", "completion": "0.0000010000"},
283
+ "best_for": "long-context RAG, summarization (thinking model)"},
284
+ ]
285
+
286
+
287
+ @main.command(help="List available models.")
288
+ @click.option("--json", "as_json", is_flag=True)
289
+ def models(as_json: bool) -> None:
290
+ # Try the live endpoint for freshness; fall back to our shipped list if
291
+ # the user isn't signed in, so `qsp models` is useful before `qsp init`.
292
+ key = _resolve_api_key()
293
+ data: list[dict] = []
294
+ if key:
295
+ try:
296
+ with httpx.Client(
297
+ base_url=DEFAULT_API_URL,
298
+ timeout=HTTP_TIMEOUT,
299
+ headers={"Authorization": f"Bearer {key}"},
300
+ ) as c:
301
+ r = c.get("/models")
302
+ if r.status_code < 400:
303
+ data = r.json().get("data", [])
304
+ except httpx.HTTPError:
305
+ data = []
306
+ if not data:
307
+ data = _MODELS_FALLBACK
308
+ _emit(data, as_json=as_json, table_fn=_print_models)
309
+
310
+
311
+ def _print_models(rows: list[dict]) -> None:
312
+ t = Table(show_lines=False)
313
+ t.add_column("id", style="bold")
314
+ t.add_column("context", justify="right")
315
+ t.add_column("prompt $/M", justify="right")
316
+ t.add_column("completion $/M", justify="right")
317
+ for m in rows:
318
+ p = m.get("pricing") or {}
319
+ ctx = m.get("context_length")
320
+ t.add_row(
321
+ m.get("id", ""),
322
+ f"{ctx:,}" if ctx else "—",
323
+ f"{float(p.get('prompt', 0)) * 1_000_000:.2f}" if p else "—",
324
+ f"{float(p.get('completion', 0)) * 1_000_000:.2f}" if p else "—",
325
+ )
326
+ _out.print(t)
327
+
328
+
329
+ # ────────────────────────── qsp chat ──────────────────────────
330
+
331
+ @main.command(help="One-shot chat completion. Echoes to stdout.")
332
+ @click.argument("prompt")
333
+ @click.option("-m", "--model", default=DEFAULT_MODEL, show_default=True)
334
+ @click.option("-s", "--system", default=None, help="Optional system prompt.")
335
+ @click.option("--max-tokens", type=int, default=None)
336
+ @click.option("--temperature", type=float, default=None)
337
+ @click.option("--stream/--no-stream", default=True, show_default=True)
338
+ @click.option("--json", "as_json", is_flag=True, help="Emit raw JSON response (implies --no-stream).")
339
+ def chat(prompt: str, model: str, system: str | None, max_tokens: int | None,
340
+ temperature: float | None, stream: bool, as_json: bool) -> None:
341
+ key = _require_key()
342
+ messages: list[dict] = []
343
+ if system:
344
+ messages.append({"role": "system", "content": system})
345
+ messages.append({"role": "user", "content": prompt})
346
+
347
+ body = {"model": model, "messages": messages}
348
+ if max_tokens is not None: body["max_tokens"] = max_tokens
349
+ if temperature is not None: body["temperature"] = temperature
350
+
351
+ # --json means we want the full structured response, so force non-streaming.
352
+ if as_json:
353
+ stream = False
354
+
355
+ if stream:
356
+ body["stream"] = True
357
+ body["stream_options"] = {"include_usage": True}
358
+ _chat_stream(key, body)
359
+ else:
360
+ _chat_sync(key, body, as_json=as_json)
361
+
362
+
363
+ def _chat_sync(key: str, body: dict, as_json: bool) -> None:
364
+ with _api_client(key) as c:
365
+ r = c.post("/chat/completions", json=body)
366
+ if r.status_code >= 400:
367
+ _err.print(f"[red]{_extract_error(r)}[/red]")
368
+ sys.exit(1)
369
+ data = r.json()
370
+ if as_json:
371
+ click.echo(json.dumps(data, indent=2))
372
+ return
373
+ msg = ((data.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
374
+ click.echo(msg)
375
+ usage = data.get("usage") or {}
376
+ cost = usage.get("cost")
377
+ if cost is not None:
378
+ _err.print(
379
+ f"[dim]{usage.get('prompt_tokens',0)} in + "
380
+ f"{usage.get('completion_tokens',0)} out · "
381
+ f"${cost:.6f}[/dim]"
382
+ )
383
+
384
+
385
+ def _chat_stream(key: str, body: dict) -> None:
386
+ last_usage = {}
387
+ try:
388
+ with _api_client(key).stream("POST", "/chat/completions", json=body) as r:
389
+ if r.status_code >= 400:
390
+ _err.print(f"[red]{_extract_error(r)}[/red]")
391
+ sys.exit(1)
392
+ for line in r.iter_lines():
393
+ if not line or not line.startswith("data:"):
394
+ continue
395
+ payload = line[5:].strip()
396
+ if not payload or payload == "[DONE]":
397
+ continue
398
+ try:
399
+ ev = json.loads(payload)
400
+ except Exception:
401
+ continue
402
+ for ch in ev.get("choices") or []:
403
+ delta = (ch.get("delta") or {})
404
+ if delta.get("content"):
405
+ sys.stdout.write(delta["content"])
406
+ sys.stdout.flush()
407
+ if ev.get("usage"):
408
+ last_usage = ev["usage"]
409
+ except httpx.HTTPError as e:
410
+ _err.print(f"\n[red]Network error:[/red] {e}")
411
+ sys.exit(1)
412
+ sys.stdout.write("\n")
413
+ if last_usage.get("cost") is not None:
414
+ _err.print(
415
+ f"[dim]{last_usage.get('prompt_tokens',0)} in + "
416
+ f"{last_usage.get('completion_tokens',0)} out · "
417
+ f"${last_usage['cost']:.6f}[/dim]"
418
+ )
419
+
420
+
421
+ # ────────────────────────── qsp keys ──────────────────────────
422
+
423
+ @main.group(help="Manage API keys.")
424
+ def keys() -> None: pass
425
+
426
+
427
+ @keys.command("list", help="List all of your API keys.")
428
+ @click.option("--json", "as_json", is_flag=True)
429
+ def keys_list(as_json: bool) -> None:
430
+ key = _require_key()
431
+ with _auth_client(key) as c:
432
+ r = c.get("/v1/keys")
433
+ if r.status_code >= 400:
434
+ _err.print(f"[red]{_extract_error(r)}[/red]")
435
+ sys.exit(1)
436
+ rows = r.json().get("keys", [])
437
+ _emit(rows, as_json=as_json, table_fn=_print_keys)
438
+
439
+
440
+ def _print_keys(rows: list[dict]) -> None:
441
+ t = Table(show_lines=False)
442
+ t.add_column("alias", style="bold")
443
+ t.add_column("key")
444
+ t.add_column("monthly limit", justify="right")
445
+ t.add_column("spend", justify="right")
446
+ t.add_column("current?")
447
+ for k in rows:
448
+ lim = k.get("monthly_limit")
449
+ t.add_row(
450
+ k.get("alias", "—"),
451
+ k.get("key_name") or "",
452
+ f"${lim:.4f}/30d" if lim is not None else "—",
453
+ f"${k.get('spend') or 0:.4f}",
454
+ "✓" if k.get("is_current") else "",
455
+ )
456
+ _out.print(t)
457
+
458
+
459
+ @keys.command("create", help="Create a new API key.")
460
+ @click.argument("alias")
461
+ @click.option("--monthly-limit", type=float, default=None, help="Max USD this key can spend per 30 days.")
462
+ @click.option("--json", "as_json", is_flag=True)
463
+ def keys_create(alias: str, monthly_limit: float | None, as_json: bool) -> None:
464
+ key = _require_key()
465
+ body = {"alias": alias}
466
+ if monthly_limit is not None:
467
+ body["monthly_limit"] = monthly_limit
468
+ with _auth_client(key) as c:
469
+ r = c.post("/v1/keys", json=body)
470
+ if r.status_code >= 400:
471
+ _err.print(f"[red]{_extract_error(r)}[/red]")
472
+ sys.exit(1)
473
+ data = r.json()
474
+ if as_json:
475
+ click.echo(json.dumps(data, indent=2))
476
+ return
477
+ _out.print(f"✓ Created key [bold]{data.get('alias')}[/bold]")
478
+ _out.print(f" {data.get('key')}")
479
+ _out.print("[yellow]Copy it now — it won't be shown again.[/yellow]")
480
+
481
+
482
+ @keys.command("delete", help="Delete an API key by alias.")
483
+ @click.argument("alias")
484
+ @click.option("-y", "--yes", is_flag=True, help="Skip the confirmation prompt.")
485
+ def keys_delete(alias: str, yes: bool) -> None:
486
+ key = _require_key()
487
+ with _auth_client(key) as c:
488
+ r = c.get("/v1/keys")
489
+ if r.status_code >= 400:
490
+ _err.print(f"[red]{_extract_error(r)}[/red]")
491
+ sys.exit(1)
492
+ matches = [k for k in r.json().get("keys", []) if k.get("alias") == alias]
493
+ if not matches:
494
+ _err.print(f"[red]No key with alias '{alias}'.[/red]")
495
+ sys.exit(1)
496
+ target = matches[0]
497
+ if not yes:
498
+ click.confirm(f"Delete key '{alias}' ({target.get('key_name')})?", abort=True)
499
+ with _auth_client(key) as c:
500
+ r = c.post("/v1/keys/delete", json={"token": target["token"]})
501
+ if r.status_code >= 400:
502
+ _err.print(f"[red]{_extract_error(r)}[/red]")
503
+ sys.exit(1)
504
+ _out.print(f"✓ Deleted [bold]{alias}[/bold].")
505
+
506
+
507
+ # ────────────────────────── qsp usage ──────────────────────────
508
+
509
+ @main.command(help="Recent API calls with cost.")
510
+ @click.option("-n", "--limit", type=int, default=10, show_default=True)
511
+ @click.option("--json", "as_json", is_flag=True)
512
+ def usage(limit: int, as_json: bool) -> None:
513
+ key = _require_key()
514
+ with _auth_client(key) as c:
515
+ r = c.get("/v1/usage")
516
+ if r.status_code >= 400:
517
+ _err.print(f"[red]{_extract_error(r)}[/red]")
518
+ sys.exit(1)
519
+ data = r.json()
520
+ data["recent"] = (data.get("recent") or [])[:limit]
521
+ _emit(data, as_json=as_json, table_fn=_print_usage)
522
+
523
+
524
+ def _print_usage(data: dict) -> None:
525
+ totals = data.get("totals") or {}
526
+ _out.print(
527
+ f"Total: [bold]{totals.get('requests',0)}[/bold] requests · "
528
+ f"{totals.get('tokens',0):,} tokens · "
529
+ f"${totals.get('cost',0):.4f}"
530
+ )
531
+ by_model = data.get("by_model") or []
532
+ if by_model:
533
+ t = Table(title="By model")
534
+ t.add_column("model", style="bold"); t.add_column("requests", justify="right")
535
+ t.add_column("tokens", justify="right"); t.add_column("cost", justify="right")
536
+ for m in by_model:
537
+ t.add_row(m["model"], str(m["requests"]), f"{m['tokens']:,}", f"${m['cost']:.4f}")
538
+ _out.print(t)
539
+ recent = data.get("recent") or []
540
+ if recent:
541
+ t = Table(title="Recent requests")
542
+ t.add_column("model", style="bold"); t.add_column("tokens", justify="right")
543
+ t.add_column("latency", justify="right"); t.add_column("cost", justify="right"); t.add_column("at")
544
+ for r in recent:
545
+ t.add_row(r["model"], str(r.get("tokens", 0)),
546
+ f"{(r.get('duration_ms') or 0)/1000:.2f}s",
547
+ f"${r.get('cost',0):.6f}", r.get("at", "")[:19])
548
+ _out.print(t)
549
+
550
+
551
+ # ────────────────────────── qsp status ──────────────────────────
552
+
553
+ @main.command(help="Check service status (routes + per-model latency).")
554
+ @click.option("--json", "as_json", is_flag=True)
555
+ def status(as_json: bool) -> None:
556
+ with httpx.Client(base_url=DEFAULT_AUTH_URL, timeout=HTTP_TIMEOUT) as c:
557
+ r = c.get("/v1/status")
558
+ if r.status_code >= 400:
559
+ _err.print(f"[red]{_extract_error(r)}[/red]")
560
+ sys.exit(1)
561
+ data = r.json()
562
+ _emit(data, as_json=as_json, table_fn=_print_status)
563
+
564
+
565
+ def _print_status(data: dict) -> None:
566
+ overall = (data.get("overall") or "unknown").upper()
567
+ color = {"OPERATIONAL": "green", "DEGRADED": "yellow",
568
+ "PARTIAL_OUTAGE": "yellow", "MAJOR_OUTAGE": "red"}.get(overall, "white")
569
+ _out.print(f"Overall: [bold {color}]{overall}[/bold {color}]")
570
+ t = Table(show_lines=False)
571
+ t.add_column("model", style="bold"); t.add_column("status")
572
+ t.add_column("latency (ms)", justify="right")
573
+ for m in data.get("models") or []:
574
+ t.add_row(m["model"], m["status"], str(m.get("latency_ms") or "—"))
575
+ _out.print(t)
576
+
577
+
578
+ # ────────────────────────── qsp pay ──────────────────────────
579
+
580
+ @main.command(help="Open the Stripe checkout for a credit pack ($5 / $20 / $50).")
581
+ @click.argument("amount", type=click.Choice(["5", "20", "50"]))
582
+ def pay(amount: str) -> None:
583
+ urls = {
584
+ "5": "https://buy.stripe.com/6oU9AT5eC2aGexN4t4gjC03",
585
+ "20": "https://buy.stripe.com/6oU5kD0YmbLg9dt9NogjC04",
586
+ "50": "https://buy.stripe.com/7sYbJ1ePc7v0ahxcZAgjC05",
587
+ }
588
+ url = urls[amount]
589
+ _out.print(f"Opening checkout for [bold]${amount}[/bold]: {url}")
590
+ try:
591
+ webbrowser.open(url)
592
+ except Exception:
593
+ pass
594
+
595
+
596
+ # ────────────────────────── entrypoint ──────────────────────────
597
+
598
+ if __name__ == "__main__":
599
+ main()
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: quicksilverpro
3
+ Version: 0.1.0
4
+ Summary: CLI for QuickSilver Pro — OpenAI-compatible API for DeepSeek V3, DeepSeek R1, and Qwen 3.5 at 20% below resellers.
5
+ Project-URL: Homepage, https://quicksilverpro.io
6
+ Project-URL: Documentation, https://quicksilverpro.io/dashboard#quickstart
7
+ Project-URL: Repository, https://github.com/machinefi/qspro-cli
8
+ Project-URL: Issues, https://github.com/machinefi/qspro-cli/issues
9
+ Project-URL: Status, https://quicksilverpro.io/status
10
+ Author-email: "MachineFi Inc." <hello@quicksilverpro.io>
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: ai,api,chatgpt,cli,deepseek,inference,llm,openai,openrouter-alternative,quicksilverpro,qwen
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Environment :: Console
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
26
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
27
+ Classifier: Topic :: Utilities
28
+ Requires-Python: >=3.9
29
+ Requires-Dist: click>=8.1
30
+ Requires-Dist: httpx>=0.25
31
+ Requires-Dist: rich>=13
32
+ Description-Content-Type: text/markdown
33
+
34
+ # QuickSilver Pro CLI
35
+
36
+ `qsp` — a command-line client for [QuickSilver Pro](https://quicksilverpro.io), an OpenAI-compatible inference API for top open-source LLMs (DeepSeek V3, DeepSeek R1, Qwen 3.5) priced 20% below OpenRouter / Together / Fireworks.
37
+
38
+ Designed to be **AI-agent friendly**: every command accepts `--json` for structured output, exit codes are reliable, and the API surface is intentionally small.
39
+
40
+ ---
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install quicksilverpro
46
+ ```
47
+
48
+ Python 3.9+. Also exports itself as `quicksilverpro` if you prefer the long name.
49
+
50
+ ---
51
+
52
+ ## Quick start
53
+
54
+ ```bash
55
+ qsp init # opens dashboard to get a key, stores it locally
56
+ qsp chat "Write me a haiku" # one-shot streaming chat (deepseek-v3 by default)
57
+ qsp balance # current credits
58
+ qsp models # supported models with prices & context length
59
+ qsp status # live per-model latency
60
+ ```
61
+
62
+ ---
63
+
64
+ ## Commands
65
+
66
+ | Command | Purpose |
67
+ |---|---|
68
+ | `qsp init [--email X] [--key sk-...]` | Sign in (browser walkthrough) or paste an existing key |
69
+ | `qsp logout` | Forget locally-stored key |
70
+ | `qsp whoami [--json]` | Show signed-in email + balance |
71
+ | `qsp balance [--json]` | Credit balance + lifetime spend |
72
+ | `qsp models [--json]` | Available models + pricing + context length |
73
+ | `qsp chat "PROMPT" [-m MODEL] [-s SYS] [--max-tokens N] [--temperature F] [--no-stream] [--json]` | One-shot completion, streams to stdout by default |
74
+ | `qsp usage [-n 10] [--json]` | Recent calls + aggregate per-model |
75
+ | `qsp status [--json]` | Live health of API + per-model probes |
76
+ | `qsp keys list [--json]` | Your API keys |
77
+ | `qsp keys create ALIAS [--monthly-limit USD] [--json]` | Create a new key with optional spend cap |
78
+ | `qsp keys delete ALIAS [-y]` | Delete a key (confirmation prompt unless `-y`) |
79
+ | `qsp pay {5,20,50}` | Opens Stripe checkout for a credit top-up |
80
+
81
+ ---
82
+
83
+ ## AI-agent usage
84
+
85
+ Every command supports `--json` and prints OpenAI-shaped JSON to stdout with errors on stderr.
86
+
87
+ ```bash
88
+ qsp models --json | jq '.[].id'
89
+ qsp usage --json | jq '.totals.cost'
90
+ qsp chat "Summarize: $DOCUMENT" --json --no-stream | jq -r '.choices[0].message.content'
91
+ ```
92
+
93
+ Exit codes: `0` success · `1` remote/operational error · `2` usage / auth error.
94
+
95
+ ---
96
+
97
+ ## Config
98
+
99
+ Key stored at `~/.config/quicksilverpro/config.json` (chmod 600). Override with:
100
+
101
+ - `QSP_API_KEY` — use this key directly, ignore stored config
102
+ - `QSP_API_URL` — default `https://api.quicksilverpro.io/v1`
103
+ - `QSP_AUTH_URL` — default `https://pay.quicksilverpro.io`
104
+ - `QSP_MODEL` — default model for `qsp chat`
105
+ - `QSP_CONFIG_DIR` — where to store config
106
+ - `QSP_HTTP_TIMEOUT` — seconds, default 60
107
+
108
+ ---
109
+
110
+ ## Use the `openai` SDK directly
111
+
112
+ You don't need this CLI to use QuickSilver Pro. The OpenAI Python / Node / Swift SDKs work with only a `base_url` change:
113
+
114
+ ```python
115
+ from openai import OpenAI
116
+ client = OpenAI(
117
+ base_url="https://api.quicksilverpro.io/v1",
118
+ api_key="sk-...", # your QuickSilver Pro key
119
+ )
120
+ r = client.chat.completions.create(
121
+ model="deepseek-v3",
122
+ messages=[{"role": "user", "content": "Hello"}],
123
+ )
124
+ ```
125
+
126
+ See [quicksilverpro.io/dashboard#quickstart](https://quicksilverpro.io/dashboard#quickstart) for JS / Swift / curl.
127
+
128
+ ---
129
+
130
+ ## License
131
+
132
+ MIT.
133
+
134
+ QuickSilver Pro is a product of MachineFi Inc. (68 Willow Rd, Menlo Park, CA).
135
+
136
+ Links: [home](https://quicksilverpro.io) · [status](https://quicksilverpro.io/status) · [terms](https://quicksilverpro.io/terms) · [privacy](https://quicksilverpro.io/privacy)
@@ -0,0 +1,8 @@
1
+ quicksilverpro/__init__.py,sha256=f1Xh2HZkS-qfLrfGe0siAkkvFnd9HZTOQ90EcivI0L4,145
2
+ quicksilverpro/__main__.py,sha256=_eH2PEyv1rf1wRjHgWo1A6gVzzc4lhQyOYGi7RgVwI8,142
3
+ quicksilverpro/cli.py,sha256=0apDmgkwwH2LM5q9eH-U6FLOonlW0Ra2axQpHjbKxbg,23310
4
+ quicksilverpro-0.1.0.dist-info/METADATA,sha256=FjmMOKo8sOGFxF1UxXB_crmzJCevfvXiQpyXApWY8kM,5073
5
+ quicksilverpro-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
6
+ quicksilverpro-0.1.0.dist-info/entry_points.txt,sha256=KOzNXf9lXekiu5-qE-EZfYzvenewcqDKcfh058x3rZg,89
7
+ quicksilverpro-0.1.0.dist-info/licenses/LICENSE,sha256=Cz3LZFBmLbrdGHKUdm_3sNkt2hTKgfkiaVsc9A-ByQA,1071
8
+ quicksilverpro-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ qsp = quicksilverpro.cli:main
3
+ quicksilverpro = quicksilverpro.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MachineFi Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.