eightstatecli 0.4.1__tar.gz → 0.4.2__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.
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/PKG-INFO +1 -1
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/pyproject.toml +1 -1
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/src/escli/commands/research.py +154 -8
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/.gitignore +0 -0
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/LICENSE +0 -0
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/README.md +0 -0
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/src/escli/__init__.py +0 -0
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/src/escli/__main__.py +0 -0
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/src/escli/commands/__init__.py +0 -0
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/src/escli/commands/audio.py +0 -0
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/src/escli/commands/docs.py +0 -0
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/src/escli/commands/search.py +0 -0
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/src/escli/commands/social.py +0 -0
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/src/escli/commands/usage.py +0 -0
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/src/escli/services/__init__.py +0 -0
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/src/escli/services/credentials.py +0 -0
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/src/escli/services/describe.py +0 -0
- {eightstatecli-0.4.1 → eightstatecli-0.4.2}/src/escli/services/output.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: eightstatecli
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Eightstate CLI — unified AI services from the command line
|
|
5
5
|
Project-URL: Homepage, https://github.com/eight-state/eightstate
|
|
6
6
|
Project-URL: Repository, https://github.com/eight-state/eightstate
|
|
@@ -27,13 +27,27 @@ import urllib.request
|
|
|
27
27
|
import urllib.error
|
|
28
28
|
from datetime import datetime, timezone
|
|
29
29
|
|
|
30
|
-
from ..services.credentials import
|
|
30
|
+
from ..services.credentials import (
|
|
31
|
+
get_key_for_service,
|
|
32
|
+
call_with_retry,
|
|
33
|
+
vend_key,
|
|
34
|
+
ServiceCallError,
|
|
35
|
+
CONFIG_DIR,
|
|
36
|
+
)
|
|
31
37
|
|
|
32
38
|
API_BASE = "https://api.parallel.ai"
|
|
33
39
|
SSE_BETA = "events-sse-2025-07-24"
|
|
34
40
|
MAX_POLL_WAIT = 7200 # 2 hours (ultra8x can take up to 2h)
|
|
35
41
|
POLL_INTERVAL = 10
|
|
36
42
|
|
|
43
|
+
# Local cache mapping run_id → fingerprint of the issuing key.
|
|
44
|
+
# Used so --status / --result vend the same key that created the run.
|
|
45
|
+
_RUN_OWNERS_FILE = CONFIG_DIR / "parallel-run-owners.json"
|
|
46
|
+
|
|
47
|
+
# Max times we'll re-vend trying to land on the issuing fingerprint
|
|
48
|
+
# before giving up and using whatever we got.
|
|
49
|
+
_STICKY_VEND_ATTEMPTS = 6
|
|
50
|
+
|
|
37
51
|
PROCESSORS = [
|
|
38
52
|
"lite", "base", "core", "core2x", "pro", "ultra",
|
|
39
53
|
"ultra2x", "ultra4x", "ultra8x",
|
|
@@ -42,6 +56,70 @@ PROCESSORS = [
|
|
|
42
56
|
]
|
|
43
57
|
|
|
44
58
|
|
|
59
|
+
# ── Run-owner cache ──────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def _load_run_owners() -> dict:
|
|
62
|
+
if _RUN_OWNERS_FILE.exists():
|
|
63
|
+
try:
|
|
64
|
+
return json.loads(_RUN_OWNERS_FILE.read_text())
|
|
65
|
+
except (json.JSONDecodeError, OSError):
|
|
66
|
+
return {}
|
|
67
|
+
return {}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _remember_run_owner(run_id: str, fingerprint: str) -> None:
|
|
71
|
+
"""Persist {run_id -> fingerprint} so later --status/--result can reuse the key."""
|
|
72
|
+
if not run_id or not fingerprint or fingerprint == "env":
|
|
73
|
+
return
|
|
74
|
+
try:
|
|
75
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
owners = _load_run_owners()
|
|
77
|
+
owners[run_id] = fingerprint
|
|
78
|
+
# Cap entries (keep last 500) to bound file size
|
|
79
|
+
if len(owners) > 500:
|
|
80
|
+
owners = dict(list(owners.items())[-500:])
|
|
81
|
+
_RUN_OWNERS_FILE.write_text(json.dumps(owners))
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _recall_run_owner(run_id: str) -> str | None:
|
|
87
|
+
return _load_run_owners().get(run_id)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _get_key_for_run(run_id: str) -> str | None:
|
|
91
|
+
"""
|
|
92
|
+
Vend a key suitable for operating on run_id.
|
|
93
|
+
|
|
94
|
+
If PARALLEL_API_KEY is set, use it (single-key mode).
|
|
95
|
+
Otherwise vend from gate. If we remember the issuing fingerprint for this
|
|
96
|
+
run, retry vending until it matches (gate is headroom-rotated, so we may
|
|
97
|
+
have to vend a few times). If we can't land on the right key, fall back
|
|
98
|
+
to whatever vended and warn — caller will see a 404 if it's the wrong org.
|
|
99
|
+
"""
|
|
100
|
+
env_key = os.environ.get("PARALLEL_API_KEY")
|
|
101
|
+
if env_key:
|
|
102
|
+
return env_key
|
|
103
|
+
|
|
104
|
+
target = _recall_run_owner(run_id)
|
|
105
|
+
last_key = None
|
|
106
|
+
for _ in range(_STICKY_VEND_ATTEMPTS):
|
|
107
|
+
result = vend_key("parallel")
|
|
108
|
+
if not result:
|
|
109
|
+
return None
|
|
110
|
+
last_key = result["api_key"]
|
|
111
|
+
if target is None or result["fingerprint"] == target:
|
|
112
|
+
return last_key
|
|
113
|
+
|
|
114
|
+
if target is not None:
|
|
115
|
+
print(
|
|
116
|
+
f" [warn] could not vend issuing key for {run_id} "
|
|
117
|
+
f"(wanted fingerprint {target[:8]}…); response may 404",
|
|
118
|
+
file=sys.stderr,
|
|
119
|
+
)
|
|
120
|
+
return last_key
|
|
121
|
+
|
|
122
|
+
|
|
45
123
|
def _get_api_key() -> str:
|
|
46
124
|
key = get_key_for_service("parallel", "PARALLEL_API_KEY")
|
|
47
125
|
if not key:
|
|
@@ -87,7 +165,24 @@ def _api_request(method: str, path: str, api_key: str,
|
|
|
87
165
|
|
|
88
166
|
# ── SSE streaming ────────────────────────────────────────────────
|
|
89
167
|
|
|
168
|
+
def _sse_error_message(payload: dict) -> str:
|
|
169
|
+
"""Pull a readable message out of a Parallel error payload."""
|
|
170
|
+
err = payload.get("error") if isinstance(payload, dict) else None
|
|
171
|
+
if isinstance(err, dict):
|
|
172
|
+
return err.get("message") or err.get("detail") or json.dumps(err)
|
|
173
|
+
return payload.get("message") or json.dumps(payload)
|
|
174
|
+
|
|
175
|
+
|
|
90
176
|
def _stream_sse(api_key: str, run_id: str, quiet: bool = False) -> dict | None:
|
|
177
|
+
"""
|
|
178
|
+
Stream task progress over SSE.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
dict — task output (terminal state: completed)
|
|
182
|
+
None — stream ended without terminal state; caller should poll
|
|
183
|
+
Exits:
|
|
184
|
+
2 — definitive error (task failed, run not found, wrong-org key)
|
|
185
|
+
"""
|
|
91
186
|
url = f"{API_BASE}/v1beta/tasks/runs/{run_id}/events"
|
|
92
187
|
seen = set()
|
|
93
188
|
|
|
@@ -103,20 +198,45 @@ def _stream_sse(api_key: str, run_id: str, quiet: bool = False) -> dict | None:
|
|
|
103
198
|
|
|
104
199
|
try:
|
|
105
200
|
resp = urllib.request.urlopen(req, timeout=600)
|
|
201
|
+
except urllib.error.HTTPError as e:
|
|
202
|
+
try:
|
|
203
|
+
err_payload = json.loads(e.read().decode(errors="replace"))
|
|
204
|
+
msg = _sse_error_message(err_payload)
|
|
205
|
+
except Exception:
|
|
206
|
+
msg = str(e)
|
|
207
|
+
print(f" ✗ SSE error ({e.code}): {msg}", file=sys.stderr)
|
|
208
|
+
sys.exit(2)
|
|
106
209
|
except Exception as e:
|
|
107
210
|
if not quiet:
|
|
108
|
-
print(f" [sse] failed: {e}", file=sys.stderr)
|
|
211
|
+
print(f" [sse] connect failed: {e}", file=sys.stderr)
|
|
109
212
|
return None
|
|
110
213
|
|
|
214
|
+
# Detect non-SSE responses (HTTP 200 + JSON error body).
|
|
215
|
+
# Parallel returns this when the API key doesn't own the run_id, or
|
|
216
|
+
# when the run_id is malformed. The Content-Type is still
|
|
217
|
+
# `text/event-stream` so we can't rely on the header — instead we
|
|
218
|
+
# buffer non-SSE lines and, if we exit the loop without ever seeing
|
|
219
|
+
# a real SSE event, treat the buffer as a JSON error body.
|
|
220
|
+
saw_sse_event = False
|
|
221
|
+
non_sse_buffer: list[str] = []
|
|
222
|
+
|
|
111
223
|
try:
|
|
112
224
|
for raw_line in resp:
|
|
113
225
|
line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
|
|
226
|
+
if not line:
|
|
227
|
+
continue
|
|
114
228
|
if not line.startswith("data: "):
|
|
229
|
+
# SSE comments start with ':' and field lines may be
|
|
230
|
+
# `event:` / `id:` / `retry:` — ignore those, but capture
|
|
231
|
+
# anything that looks like raw content for error-detect.
|
|
232
|
+
if line[:1] not in (":",) and not line.startswith(("event:", "id:", "retry:")):
|
|
233
|
+
non_sse_buffer.append(line)
|
|
115
234
|
continue
|
|
116
235
|
try:
|
|
117
236
|
event = json.loads(line[6:])
|
|
118
237
|
except json.JSONDecodeError:
|
|
119
238
|
continue
|
|
239
|
+
saw_sse_event = True
|
|
120
240
|
|
|
121
241
|
etype = event.get("type", "")
|
|
122
242
|
|
|
@@ -144,12 +264,27 @@ def _stream_sse(api_key: str, run_id: str, quiet: bool = False) -> dict | None:
|
|
|
144
264
|
sys.exit(2)
|
|
145
265
|
|
|
146
266
|
elif etype == "error":
|
|
147
|
-
|
|
267
|
+
msg = _sse_error_message(event)
|
|
268
|
+
print(f" ✗ SSE error event: {msg}", file=sys.stderr)
|
|
269
|
+
sys.exit(2)
|
|
148
270
|
except Exception:
|
|
149
271
|
pass
|
|
150
272
|
finally:
|
|
151
273
|
resp.close()
|
|
152
274
|
|
|
275
|
+
# If the stream produced zero SSE events but had body content,
|
|
276
|
+
# it's almost certainly an error response (wrong-org key, bad run_id).
|
|
277
|
+
# Surface it and bail rather than silently reconnecting 25 times.
|
|
278
|
+
if not saw_sse_event and non_sse_buffer:
|
|
279
|
+
body = "\n".join(non_sse_buffer)
|
|
280
|
+
try:
|
|
281
|
+
payload = json.loads(body)
|
|
282
|
+
msg = _sse_error_message(payload)
|
|
283
|
+
except json.JSONDecodeError:
|
|
284
|
+
msg = body[:300]
|
|
285
|
+
print(f" ✗ SSE refused: {msg}", file=sys.stderr)
|
|
286
|
+
sys.exit(2)
|
|
287
|
+
|
|
153
288
|
return None
|
|
154
289
|
|
|
155
290
|
|
|
@@ -345,10 +480,15 @@ def cmd_run(args):
|
|
|
345
480
|
if getattr(args, "follow_up", None):
|
|
346
481
|
body["previous_interaction_id"] = args.follow_up
|
|
347
482
|
|
|
348
|
-
# Submit with retry — vend key, submit task, retry on 402/429
|
|
483
|
+
# Submit with retry — vend key, submit task, retry on 402/429.
|
|
484
|
+
# Capture the issuing VendedKey so SSE + polling use the SAME key.
|
|
485
|
+
# Without this, gate may vend a different org's key for the followup
|
|
486
|
+
# GET/SSE calls, causing the streamed run_id to look "not found".
|
|
349
487
|
headers = {"parallel-beta": SSE_BETA}
|
|
488
|
+
captured: dict = {"key": None}
|
|
350
489
|
|
|
351
490
|
def submit(key):
|
|
491
|
+
captured["key"] = key
|
|
352
492
|
return _api_request("POST", "/v1/tasks/runs", key.api_key,
|
|
353
493
|
body=body, extra_headers=headers, raise_on_error=True)
|
|
354
494
|
|
|
@@ -385,11 +525,17 @@ def cmd_run(args):
|
|
|
385
525
|
print(" ✗ no Parallel API key. Set PARALLEL_API_KEY or add one via the dashboard.", file=sys.stderr)
|
|
386
526
|
return 1
|
|
387
527
|
|
|
388
|
-
#
|
|
389
|
-
|
|
528
|
+
# Reuse the SAME key that submitted the task for SSE + polling.
|
|
529
|
+
vended = captured["key"]
|
|
530
|
+
api_key = vended.api_key if vended is not None else _get_api_key()
|
|
390
531
|
run_id = task["run_id"]
|
|
391
532
|
created_at = task.get("created_at", "")
|
|
392
533
|
|
|
534
|
+
# Remember which gate-vended key owns this run, so later
|
|
535
|
+
# `escli research --status/--result <run_id>` can vend the same one.
|
|
536
|
+
if vended is not None:
|
|
537
|
+
_remember_run_owner(run_id, vended.fingerprint)
|
|
538
|
+
|
|
393
539
|
if not args.quiet:
|
|
394
540
|
print(f" · run_id: {run_id}", file=sys.stderr)
|
|
395
541
|
|
|
@@ -436,7 +582,7 @@ def cmd_run(args):
|
|
|
436
582
|
|
|
437
583
|
def cmd_status(args):
|
|
438
584
|
"""Check task run status."""
|
|
439
|
-
api_key = _get_api_key()
|
|
585
|
+
api_key = _get_key_for_run(args.run_id) or _get_api_key()
|
|
440
586
|
result = _api_request("GET", f"/v1/tasks/runs/{args.run_id}", api_key)
|
|
441
587
|
|
|
442
588
|
if args.json:
|
|
@@ -511,7 +657,7 @@ def cmd_processors(args):
|
|
|
511
657
|
|
|
512
658
|
def cmd_result(args):
|
|
513
659
|
"""Fetch completed task result."""
|
|
514
|
-
api_key = _get_api_key()
|
|
660
|
+
api_key = _get_key_for_run(args.run_id) or _get_api_key()
|
|
515
661
|
|
|
516
662
|
# Check status first
|
|
517
663
|
run = _api_request("GET", f"/v1/tasks/runs/{args.run_id}", api_key)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|