sference-cli 0.0.1__tar.gz
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,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sference-cli"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "sference command-line interface"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"typer>=0.24.1",
|
|
8
|
+
"sference-sdk>=0.1.0",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
sference = "sference_cli.main:main"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling>=1.29.0"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["sference_cli"]
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.sdist]
|
|
22
|
+
include = ["sference_cli"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
|
@@ -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)
|