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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eightstatecli
3
- Version: 0.4.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "eightstatecli"
3
- version = "0.4.1"
3
+ version = "0.4.2"
4
4
  description = "Eightstate CLI — unified AI services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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 get_key_for_service, call_with_retry, ServiceCallError
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
- return None
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
- # From here on, use the same key for polling (already authenticated)
389
- api_key = _get_api_key()
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