sference-cli 0.0.1__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 @@
1
+ __all__ = []
sference_cli/main.py ADDED
@@ -0,0 +1,635 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import time
7
+ import webbrowser
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from typing import Any, Optional, TypeVar
11
+
12
+ import typer
13
+
14
+ from sference_sdk import ApiError, SferenceClient
15
+ from sference_sdk.checkpoint import clear_checkpoint, load_checkpoint, save_checkpoint
16
+
17
+ from . import stream_cache as stream_cache_mod
18
+
19
+ _T = TypeVar("_T")
20
+
21
+
22
+ app = typer.Typer(help="sference CLI", invoke_without_command=True)
23
+ auth_app = typer.Typer(help="Auth commands", invoke_without_command=True)
24
+ batch_app = typer.Typer(help="Batch commands", invoke_without_command=True)
25
+ stream_app = typer.Typer(help="Stream commands", invoke_without_command=True)
26
+ app.add_typer(auth_app, name="auth")
27
+ app.add_typer(batch_app, name="batch")
28
+ app.add_typer(stream_app, name="stream")
29
+
30
+ CREDENTIALS_PATH = Path.home() / ".sference" / "credentials.json"
31
+
32
+ DEFAULT_CONSOLE_URL = "https://console.sference.com"
33
+
34
+
35
+ def _write_token(token: str) -> None:
36
+ CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
37
+ CREDENTIALS_PATH.write_text(json.dumps({"token": token}), encoding="utf-8")
38
+
39
+
40
+ def _read_token() -> str | None:
41
+ env = os.environ.get("SFERENCE_API_KEY")
42
+ if env:
43
+ return env.strip() or None
44
+ if not CREDENTIALS_PATH.exists():
45
+ return None
46
+ try:
47
+ payload = json.loads(CREDENTIALS_PATH.read_text(encoding="utf-8"))
48
+ tok = payload.get("token")
49
+ return tok.strip() if isinstance(tok, str) else None
50
+ except Exception:
51
+ return None
52
+
53
+
54
+ def _client(base_url: Optional[str] = None) -> SferenceClient:
55
+ return SferenceClient(base_url=base_url, api_key=_read_token())
56
+
57
+
58
+ def _ensure_api_credential() -> None:
59
+ if _read_token() is None:
60
+ typer.echo(
61
+ "No API credential configured.\n"
62
+ " • Run: sference auth login\n"
63
+ " • Or: sference auth login --api-key 'sk_...'\n"
64
+ " • Or set environment variable SFERENCE_API_KEY",
65
+ err=True,
66
+ )
67
+ raise typer.Exit(code=1)
68
+
69
+
70
+ def _call_api(fn: Callable[[], _T]) -> _T:
71
+ try:
72
+ return fn()
73
+ except ApiError as exc:
74
+ err = str(exc)
75
+ if err.startswith("401:"):
76
+ typer.echo(
77
+ "Unauthorized (401). Create a key in Console → API Keys (while signed in), then run:\n"
78
+ " sference auth login --api-key 'sk_...'\n"
79
+ "If SFERENCE_API_KEY is set, it overrides ~/.sference/credentials.json.\n"
80
+ "If you already saved a key, it may be revoked or not registered for this API/database.",
81
+ err=True,
82
+ )
83
+ raise typer.Exit(code=1) from None
84
+ typer.echo(err, err=True)
85
+ raise typer.Exit(code=1) from None
86
+
87
+
88
+ def _batch_id_and_status(obj: object) -> tuple[str, str]:
89
+ if hasattr(obj, "model_dump"):
90
+ d = obj.model_dump()
91
+ return str(d["id"]), str(d["status"])
92
+ bid = getattr(obj, "id", None)
93
+ st = getattr(obj, "status", None)
94
+ return str(bid), str(st)
95
+
96
+
97
+ def _get_batch_or_missing(client: SferenceClient, batch_id: str) -> Any | None:
98
+ try:
99
+ return client.get_batch(batch_id)
100
+ except ApiError as exc:
101
+ err = str(exc)
102
+ if err.startswith("404:"):
103
+ return None
104
+ if err.startswith("401:"):
105
+ typer.echo(
106
+ "Unauthorized (401). Create a key in Console → API Keys (while signed in), then run:\n"
107
+ " sference auth login --api-key 'sk_...'\n"
108
+ "If SFERENCE_API_KEY is set, it overrides ~/.sference/credentials.json.\n"
109
+ "If you already saved a key, it may be revoked or not registered for this API/database.",
110
+ err=True,
111
+ )
112
+ raise typer.Exit(code=1) from None
113
+ typer.echo(err, err=True)
114
+ raise typer.Exit(code=1) from None
115
+
116
+
117
+ def _print(value: object, as_json: bool) -> None:
118
+ if as_json:
119
+ typer.echo(json.dumps(value, indent=2, default=str))
120
+ else:
121
+ typer.echo(value)
122
+
123
+
124
+ def _mvp_batch_window_only(value: str) -> str:
125
+ if value != "24h":
126
+ raise typer.BadParameter('Only "24h" is supported in MVP.', param_hint="--window")
127
+ return value
128
+
129
+
130
+ def _stream_window_only(value: str) -> str:
131
+ if value not in ("1h", "24h"):
132
+ raise typer.BadParameter('Window must be "1h" or "24h".', param_hint="--window")
133
+ return value
134
+
135
+
136
+ @app.callback()
137
+ def _root(ctx: typer.Context) -> None:
138
+ """Print help when invoked without a command."""
139
+ if ctx.invoked_subcommand is None:
140
+ typer.echo(ctx.get_help())
141
+ raise typer.Exit(code=0)
142
+
143
+
144
+ @auth_app.callback()
145
+ def _auth_root(ctx: typer.Context) -> None:
146
+ """Print help when invoked without a command."""
147
+ if ctx.invoked_subcommand is None:
148
+ typer.echo(ctx.get_help())
149
+ raise typer.Exit(code=0)
150
+
151
+
152
+ @batch_app.callback()
153
+ def _batch_root(ctx: typer.Context) -> None:
154
+ """Print help when invoked without a command."""
155
+ if ctx.invoked_subcommand is None:
156
+ typer.echo(ctx.get_help())
157
+ raise typer.Exit(code=0)
158
+
159
+
160
+ @stream_app.callback()
161
+ def _stream_root(ctx: typer.Context) -> None:
162
+ """Print help when invoked without a command."""
163
+ if ctx.invoked_subcommand is None:
164
+ typer.echo(ctx.get_help())
165
+ raise typer.Exit(code=0)
166
+
167
+
168
+ @auth_app.command("login")
169
+ def auth_login(
170
+ api_key: Optional[str] = typer.Option(
171
+ None,
172
+ "--api-key",
173
+ help="API key (sk_...) or JWT. Non-interactive: saves immediately (e.g. CI).",
174
+ ),
175
+ console_url: Optional[str] = typer.Option(
176
+ None,
177
+ "--console-url",
178
+ envvar="SFERENCE_CONSOLE_URL",
179
+ help="Console base URL for the browser step (default: https://console.sference.com).",
180
+ ),
181
+ no_browser: bool = typer.Option(False, "--no-browser", help="Do not open a browser; print the URL and prompt only."),
182
+ ) -> None:
183
+ """Authenticate: store an API key for API requests (Baseten-style: optional --api-key for non-interactive)."""
184
+ if api_key is not None:
185
+ key = api_key.strip()
186
+ if not key:
187
+ typer.echo("Empty --api-key.", err=True)
188
+ raise typer.Exit(code=1)
189
+ _write_token(key)
190
+ typer.echo(f"Credentials saved to {CREDENTIALS_PATH}")
191
+ return
192
+
193
+ base = (console_url or DEFAULT_CONSOLE_URL).rstrip("/")
194
+ login_url = f"{base}/login"
195
+ api_keys_url = f"{base}/api-keys"
196
+
197
+ if not no_browser:
198
+ typer.echo(f"Opening {login_url} in your browser...")
199
+ try:
200
+ webbrowser.open(login_url)
201
+ except Exception:
202
+ typer.echo("Could not open the browser automatically.")
203
+
204
+ typer.echo("")
205
+ typer.echo("After signing in:")
206
+ typer.echo(f" 1. Open {api_keys_url}")
207
+ typer.echo(" 2. Create an API key")
208
+ typer.echo(" 3. Paste it below")
209
+ typer.echo("")
210
+ token = typer.prompt("API key", hide_input=True)
211
+ if not token.strip():
212
+ typer.echo("No API key provided.", err=True)
213
+ raise typer.Exit(code=1)
214
+ _write_token(token.strip())
215
+ typer.echo(f"Credentials saved to {CREDENTIALS_PATH}")
216
+
217
+
218
+ @auth_app.command("me")
219
+ def auth_me(
220
+ base_url: str = typer.Option("https://api.sference.com"),
221
+ as_json: bool = typer.Option(False, "--json"),
222
+ ) -> None:
223
+ _ensure_api_credential()
224
+ client = _client(base_url)
225
+ me = _call_api(client.get_me)
226
+ _print(me, as_json)
227
+
228
+
229
+ @batch_app.command("list")
230
+ def batch_list(
231
+ base_url: str = typer.Option("https://api.sference.com"),
232
+ as_json: bool = typer.Option(False, "--json"),
233
+ ) -> None:
234
+ _ensure_api_credential()
235
+ client = _client(base_url)
236
+ payload = _call_api(lambda: client.list_batches().model_dump())
237
+ if as_json:
238
+ _print(payload, True)
239
+ return
240
+ if not isinstance(payload, dict):
241
+ typer.echo(str(payload))
242
+ return
243
+ items = payload.get("items")
244
+ if not isinstance(items, list):
245
+ typer.echo(str(payload))
246
+ return
247
+ if not items:
248
+ typer.echo("No batches.")
249
+ return
250
+ w_id, w_status, w_reqs, w_tok = 36, 12, 6, 10
251
+ typer.echo(
252
+ f"{'id':<{w_id}} {'status':<{w_status}} {'reqs':>{w_reqs}} {'tokens':>{w_tok}}"
253
+ )
254
+ for it in items:
255
+ if not isinstance(it, dict):
256
+ continue
257
+ bid = str(it.get("id", ""))
258
+ if len(bid) > w_id:
259
+ bid = bid[: w_id - 3] + "..."
260
+ status = str(it.get("status", ""))
261
+ if len(status) > w_status:
262
+ status = status[: w_status - 1] + "…"
263
+ reqs = int(it.get("request_count") or 0)
264
+ tokens = int(it.get("total_tokens") or 0)
265
+ typer.echo(f"{bid:<{w_id}} {status:<{w_status}} {reqs:>{w_reqs}} {tokens:>{w_tok}}")
266
+
267
+
268
+ @batch_app.command("submit")
269
+ def batch_submit(
270
+ input_file: str = typer.Option(..., "--input-file"),
271
+ model: Optional[str] = typer.Option(None, "--model", help="Required for content-only JSONL; ignored per line for OpenAI-compatible JSONL."),
272
+ window: str = typer.Option(
273
+ "24h",
274
+ "--window",
275
+ help='Batch SLA window (MVP: only "24h").',
276
+ callback=_mvp_batch_window_only,
277
+ ),
278
+ base_url: str = typer.Option("https://api.sference.com"),
279
+ as_json: bool = typer.Option(False, "--json"),
280
+ ) -> None:
281
+ _ensure_api_credential()
282
+ client = _client(base_url)
283
+ batch = _call_api(lambda: client.submit_batch(input_file=input_file, model=model, window=window))
284
+ _print(batch.model_dump(), as_json)
285
+
286
+
287
+ @batch_app.command("status")
288
+ def batch_status(
289
+ batch_id: str = typer.Option(..., "--batch-id"),
290
+ base_url: str = typer.Option("https://api.sference.com"),
291
+ as_json: bool = typer.Option(False, "--json"),
292
+ ) -> None:
293
+ _ensure_api_credential()
294
+ client = _client(base_url)
295
+ batch = _call_api(lambda: client.get_batch(batch_id))
296
+ _print(batch.model_dump(), as_json)
297
+
298
+
299
+ @batch_app.command("stream")
300
+ def batch_stream(
301
+ input_file: str = typer.Option(..., "--input-file"),
302
+ model: Optional[str] = typer.Option(None, "--model", help="Required for content-only JSONL; ignored per line for OpenAI-compatible JSONL."),
303
+ window: str = typer.Option(
304
+ "24h",
305
+ "--window",
306
+ help='Batch SLA window (MVP: only "24h").',
307
+ callback=_mvp_batch_window_only,
308
+ ),
309
+ poll_interval: float = typer.Option(2.0, "--poll-interval", help="Seconds between status polls while the batch is running."),
310
+ no_cache: bool = typer.Option(
311
+ False,
312
+ "--no-cache",
313
+ help="Skip resumable cache; always submit a new batch.",
314
+ ),
315
+ base_url: str = typer.Option("https://api.sference.com"),
316
+ ) -> None:
317
+ """Submit a JSONL batch, wait until it finishes, print JSONL results to stdout (progress on stderr).
318
+
319
+ The same input file content can be resumed after Ctrl+C: a cache maps file hash → batch_id
320
+ under ~/.sference/stream_cache.json (override with SFERENCE_STREAM_CACHE).
321
+ """
322
+ _ensure_api_credential()
323
+ path = Path(input_file)
324
+ if not path.is_file():
325
+ typer.echo(f"Not a file: {input_file}", err=True)
326
+ raise typer.Exit(code=1)
327
+
328
+ client = _client(base_url)
329
+ key = stream_cache_mod.cache_key_from_file(path)
330
+ bu = base_url.rstrip("/")
331
+
332
+ batch: object | None = None
333
+ batch_id: str | None = None
334
+ terminal = ("completed", "failed", "cancelled")
335
+
336
+ if not no_cache:
337
+ cached_id = stream_cache_mod.get_cached_batch_id(key, bu)
338
+ if cached_id:
339
+ batch = _get_batch_or_missing(client, cached_id)
340
+ if batch is None:
341
+ stream_cache_mod.remove_cached_batch(key)
342
+ else:
343
+ batch_id, st_cached = _batch_id_and_status(batch)
344
+ if st_cached not in terminal:
345
+ typer.echo(f"Resuming batch {batch_id}…", err=True)
346
+
347
+ if batch_id is None:
348
+ batch = _call_api(lambda: client.submit_batch(input_file=input_file, model=model, window=window))
349
+ batch_id, _st = _batch_id_and_status(batch)
350
+ if not no_cache:
351
+ stream_cache_mod.set_cached_batch(key, batch_id, bu)
352
+
353
+ assert batch_id is not None
354
+ assert batch is not None
355
+ start = time.monotonic()
356
+ _, status = _batch_id_and_status(batch)
357
+ while status not in terminal:
358
+ elapsed = int(time.monotonic() - start)
359
+ typer.echo(f"Batch {batch_id} status={status} ({elapsed}s)", err=True)
360
+ time.sleep(poll_interval)
361
+ batch = _call_api(lambda: client.get_batch(batch_id))
362
+ _, status = _batch_id_and_status(batch)
363
+
364
+ elapsed = int(time.monotonic() - start)
365
+ typer.echo(f"Batch {batch_id} status={status} ({elapsed}s)", err=True)
366
+
367
+ try:
368
+ _call_api(lambda: client.download_results_jsonl(batch_id, sys.stdout.buffer))
369
+ finally:
370
+ if not no_cache:
371
+ stream_cache_mod.remove_cached_batch(key)
372
+
373
+ raise typer.Exit(code=0 if status == "completed" else 1)
374
+
375
+
376
+ @batch_app.command("wait")
377
+ def batch_wait(
378
+ batch_id: str = typer.Option(..., "--batch-id"),
379
+ poll_interval: float = typer.Option(1.0, "--poll-interval"),
380
+ timeout: float = typer.Option(30.0, "--timeout"),
381
+ base_url: str = typer.Option("https://api.sference.com"),
382
+ as_json: bool = typer.Option(False, "--json"),
383
+ ) -> None:
384
+ _ensure_api_credential()
385
+ client = _client(base_url)
386
+ batch = _call_api(
387
+ lambda: client.wait_for_completion(batch_id, poll_interval=poll_interval, timeout=timeout)
388
+ )
389
+ _print(batch.model_dump(), as_json)
390
+
391
+
392
+ @batch_app.command("results")
393
+ def batch_results(
394
+ batch_id: str = typer.Option(..., "--batch-id"),
395
+ base_url: str = typer.Option("https://api.sference.com"),
396
+ as_json: bool = typer.Option(False, "--json"),
397
+ ) -> None:
398
+ _ensure_api_credential()
399
+ client = _client(base_url)
400
+ results = _call_api(lambda: client.get_results(batch_id))
401
+ _print(results.model_dump(), as_json)
402
+
403
+
404
+ @batch_app.command("cancel")
405
+ def batch_cancel(
406
+ batch_id: str = typer.Option(..., "--batch-id"),
407
+ base_url: str = typer.Option("https://api.sference.com"),
408
+ as_json: bool = typer.Option(False, "--json"),
409
+ ) -> None:
410
+ _ensure_api_credential()
411
+ client = _client(base_url)
412
+ batch = _call_api(lambda: client.cancel_batch(batch_id))
413
+ _print(batch.model_dump(), as_json)
414
+
415
+
416
+ @stream_app.command("create")
417
+ def stream_create(
418
+ name: str = typer.Option(..., "--name"),
419
+ window: str = typer.Option(
420
+ "24h",
421
+ "--window",
422
+ help='Per-item SLA window: "1h" or "24h" (recorded only in MVP).',
423
+ callback=_stream_window_only,
424
+ ),
425
+ base_url: str = typer.Option("https://api.sference.com"),
426
+ as_json: bool = typer.Option(False, "--json"),
427
+ ) -> None:
428
+ _ensure_api_credential()
429
+ client = _client(base_url)
430
+ stream = _call_api(lambda: client.create_stream(name=name, window=window))
431
+ _print(stream.model_dump(), as_json)
432
+
433
+
434
+ @stream_app.command("list")
435
+ def stream_list(
436
+ base_url: str = typer.Option("https://api.sference.com"),
437
+ as_json: bool = typer.Option(False, "--json"),
438
+ ) -> None:
439
+ _ensure_api_credential()
440
+ client = _client(base_url)
441
+ payload = _call_api(lambda: client.list_streams().model_dump())
442
+ _print(payload, as_json)
443
+
444
+
445
+ @stream_app.command("status")
446
+ def stream_status(
447
+ stream_id: str = typer.Option(..., "--stream-id"),
448
+ base_url: str = typer.Option("https://api.sference.com"),
449
+ as_json: bool = typer.Option(False, "--json"),
450
+ ) -> None:
451
+ _ensure_api_credential()
452
+ client = _client(base_url)
453
+ st = _call_api(lambda: client.get_stream(stream_id))
454
+ _print(st.model_dump(), as_json)
455
+
456
+
457
+ @stream_app.command("submit")
458
+ def stream_submit(
459
+ stream_id: str = typer.Option(..., "--stream-id"),
460
+ input_file: str = typer.Option(
461
+ ...,
462
+ "--input-file",
463
+ help="JSONL per line: OpenAI-compatible {input: [{role, content}]} or content-only {content} (requires --model).",
464
+ ),
465
+ model: Optional[str] = typer.Option(
466
+ None,
467
+ "--model",
468
+ help="Required for content-only JSONL; creates responses using /v1/responses with metadata.stream_id.",
469
+ ),
470
+ base_url: str = typer.Option("https://api.sference.com"),
471
+ as_json: bool = typer.Option(False, "--json"),
472
+ ) -> None:
473
+ """Submit items to a stream using the unified /v1/responses endpoint.
474
+
475
+ Each line in the JSONL file creates a response associated with the stream
476
+ via metadata.stream_id. The responses are queued and processed by the
477
+ inference worker.
478
+ """
479
+ _ensure_api_credential()
480
+ path = Path(input_file)
481
+ if not path.is_file():
482
+ typer.echo(f"Not a file: {input_file}", err=True)
483
+ raise typer.Exit(code=1)
484
+
485
+ if not model:
486
+ typer.echo("--model is required for stream submit (e.g., gpt-4o)", err=True)
487
+ raise typer.Exit(code=1)
488
+
489
+ client = _client(base_url)
490
+
491
+ # Read and parse the JSONL file
492
+ lines = path.read_text(encoding="utf-8").strip().split("\n")
493
+ created_responses = []
494
+
495
+ for line in lines:
496
+ if not line.strip():
497
+ continue
498
+ try:
499
+ data = json.loads(line)
500
+ except json.JSONDecodeError as e:
501
+ typer.echo(f"Invalid JSON in input file: {e}", err=True)
502
+ raise typer.Exit(code=1)
503
+
504
+ # Handle content-only format: {"content": "..."}
505
+ if "content" in data and "input" not in data:
506
+ body = {
507
+ "model": model,
508
+ "input": [{"role": "user", "content": data["content"]}],
509
+ "metadata": {"stream_id": stream_id},
510
+ }
511
+ # Handle OpenAI-compatible format with input
512
+ elif "input" in data:
513
+ body = {
514
+ "model": model,
515
+ "input": data["input"],
516
+ "metadata": {"stream_id": stream_id, **data.get("metadata", {})},
517
+ }
518
+ else:
519
+ typer.echo(f"Unrecognized line format: {line[:100]}", err=True)
520
+ raise typer.Exit(code=1)
521
+
522
+ resp = _call_api(lambda: client.create_response(body))
523
+ created_responses.append(resp)
524
+
525
+ # Return stream detail-like response for compatibility
526
+ result = {
527
+ "stream_id": stream_id,
528
+ "total_items": len(created_responses),
529
+ "items": [r.model_dump() for r in created_responses],
530
+ }
531
+ _print(result, as_json)
532
+
533
+
534
+ @stream_app.command("archive")
535
+ def stream_archive(
536
+ stream_id: str = typer.Option(..., "--stream-id"),
537
+ base_url: str = typer.Option("https://api.sference.com"),
538
+ as_json: bool = typer.Option(False, "--json"),
539
+ ) -> None:
540
+ _ensure_api_credential()
541
+ client = _client(base_url)
542
+ st = _call_api(lambda: client.archive_stream(stream_id))
543
+ _print(st.model_dump(), as_json)
544
+
545
+
546
+ @stream_app.command("cancel")
547
+ def stream_cancel(
548
+ stream_id: str = typer.Option(..., "--stream-id"),
549
+ base_url: str = typer.Option("https://api.sference.com"),
550
+ as_json: bool = typer.Option(False, "--json"),
551
+ ) -> None:
552
+ """Stop accepting new items and stop enqueueing pending work; in-flight items are not auto-cancelled."""
553
+ _ensure_api_credential()
554
+ client = _client(base_url)
555
+ st = _call_api(lambda: client.cancel_stream(stream_id))
556
+ _print(st.model_dump(), as_json)
557
+
558
+
559
+ @stream_app.command("tail")
560
+ def stream_tail(
561
+ stream_id: str = typer.Option(..., "--stream-id"),
562
+ consumer: str = typer.Option("default", "--consumer", help="Checkpoint namespace for resume."),
563
+ from_latest: bool = typer.Option(
564
+ False,
565
+ "--from-latest",
566
+ help="Ignore saved checkpoint and start from the latest events page.",
567
+ ),
568
+ no_checkpoint: bool = typer.Option(False, "--no-checkpoint", help="Do not read or write checkpoints."),
569
+ poll_ms: int = typer.Option(5000, "--poll-ms", help="Long-poll wait_ms when catching up (max 30000)."),
570
+ base_url: str = typer.Option("https://api.sference.com"),
571
+ as_json: bool = typer.Option(False, "--json"),
572
+ ) -> None:
573
+ """Print stream result events as JSONL (stdout). Checkpoints after each event unless --no-checkpoint."""
574
+ _ = as_json
575
+ _ensure_api_credential()
576
+ client = _client(base_url)
577
+ bu = base_url.rstrip("/")
578
+
579
+ if from_latest and not no_checkpoint:
580
+ clear_checkpoint(bu, stream_id, consumer)
581
+
582
+ last: str | None = None
583
+ if not no_checkpoint and not from_latest:
584
+ last = load_checkpoint(bu, stream_id, consumer)
585
+
586
+ try:
587
+ while True:
588
+
589
+ def fetch() -> Any:
590
+ return client.list_stream_events(
591
+ stream_id,
592
+ limit=50,
593
+ starting_after=last,
594
+ wait_ms=min(poll_ms, 30000),
595
+ )
596
+
597
+ page = _call_api(fetch)
598
+ if not page.data:
599
+ time.sleep(max(1, poll_ms) / 1000.0)
600
+ continue
601
+ for ev in page.data:
602
+ typer.echo(json.dumps(ev.model_dump(), default=str))
603
+ last = ev.completion_id
604
+ if not no_checkpoint:
605
+ save_checkpoint(bu, stream_id, consumer, ev.completion_id)
606
+ except KeyboardInterrupt:
607
+ typer.echo("Interrupted.", err=True)
608
+ raise typer.Exit(code=130) from None
609
+
610
+
611
+ @batch_app.command("download-results")
612
+ def batch_download_results(
613
+ batch_id: str = typer.Option(..., "--batch-id"),
614
+ out: Path = typer.Option(..., "--out", help="Output path for downloaded results."),
615
+ format: str = typer.Option("jsonl", "--format", help="Download format (only jsonl is supported)."),
616
+ base_url: str = typer.Option("https://api.sference.com"),
617
+ ) -> None:
618
+ _ensure_api_credential()
619
+ if format.lower() != "jsonl":
620
+ typer.echo("Only --format jsonl is supported.", err=True)
621
+ raise typer.Exit(code=1)
622
+ client = _client(base_url)
623
+
624
+ out.parent.mkdir(parents=True, exist_ok=True)
625
+ with out.open("wb") as f:
626
+ _call_api(lambda: client.download_results_jsonl(batch_id, f))
627
+ typer.echo(str(out))
628
+
629
+
630
+ def main() -> None:
631
+ app()
632
+
633
+
634
+ if __name__ == "__main__":
635
+ main()
@@ -0,0 +1,67 @@
1
+ """Resumable mapping from batch input file content hash to batch_id for `sference batch stream`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ def _cache_file_path() -> Path:
14
+ override = os.environ.get("SFERENCE_STREAM_CACHE")
15
+ if override:
16
+ return Path(override)
17
+ return Path.home() / ".sference" / "stream_cache.json"
18
+
19
+
20
+ def cache_key_from_file(path: Path) -> str:
21
+ """SHA-256 hex digest of raw file bytes (same content => same key regardless of path)."""
22
+ return hashlib.sha256(path.read_bytes()).hexdigest()
23
+
24
+
25
+ def _load_all() -> dict[str, Any]:
26
+ p = _cache_file_path()
27
+ if not p.exists():
28
+ return {}
29
+ try:
30
+ data = json.loads(p.read_text(encoding="utf-8"))
31
+ return data if isinstance(data, dict) else {}
32
+ except (OSError, json.JSONDecodeError):
33
+ return {}
34
+
35
+
36
+ def _save_all(data: dict[str, Any]) -> None:
37
+ p = _cache_file_path()
38
+ p.parent.mkdir(parents=True, exist_ok=True)
39
+ p.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
40
+
41
+
42
+ def get_cached_batch_id(key: str, base_url: str) -> str | None:
43
+ """Return batch_id if cache has an entry for this content hash and matching base_url."""
44
+ entry = _load_all().get(key)
45
+ if not isinstance(entry, dict):
46
+ return None
47
+ if entry.get("base_url") != base_url.rstrip("/"):
48
+ return None
49
+ bid = entry.get("batch_id")
50
+ return str(bid) if bid else None
51
+
52
+
53
+ def set_cached_batch(key: str, batch_id: str, base_url: str) -> None:
54
+ data = _load_all()
55
+ data[key] = {
56
+ "batch_id": batch_id,
57
+ "base_url": base_url.rstrip("/"),
58
+ "created_at": datetime.now(tz=timezone.utc).isoformat(),
59
+ }
60
+ _save_all(data)
61
+
62
+
63
+ def remove_cached_batch(key: str) -> None:
64
+ data = _load_all()
65
+ if key in data:
66
+ del data[key]
67
+ _save_all(data)
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: sference-cli
3
+ Version: 0.0.1
4
+ Summary: sference command-line interface
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: sference-sdk>=0.1.0
7
+ Requires-Dist: typer>=0.24.1
@@ -0,0 +1,7 @@
1
+ sference_cli/__init__.py,sha256=da1PTClDMl-IBkrSvq6JC1lnS-K_BASzCvxVhNxN5Ls,13
2
+ sference_cli/main.py,sha256=OUucae58w6v6-Ngs-G_MxUx2RbjcrF0wY0vgWYjzmxs,21802
3
+ sference_cli/stream_cache.py,sha256=dYzsHp2iWF2bj8JevVdThVaaCKjkztcUposNpRaZKzU,1943
4
+ sference_cli-0.0.1.dist-info/METADATA,sha256=PQhmN351xKz6zEmO5-nf1UximzGoh22MIk0OoxlAmDo,185
5
+ sference_cli-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
6
+ sference_cli-0.0.1.dist-info/entry_points.txt,sha256=JkFhzUu37Q6aUJ0P-FccUfzpi0rxrzH1vtvbLQyVTH0,52
7
+ sference_cli-0.0.1.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,2 @@
1
+ [console_scripts]
2
+ sference = sference_cli.main:main