eightstatecli 0.4.1__tar.gz → 0.4.3__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.3
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.3"
4
4
  description = "Eightstate CLI — unified AI services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -17,7 +17,7 @@ AGENT INSTRUCTIONS:
17
17
  - Exit codes: 0=success, 1=error, 2=usage
18
18
  """
19
19
 
20
- __version__ = "0.4.1"
20
+ __version__ = "0.4.3"
21
21
 
22
22
  import argparse
23
23
  import base64
@@ -718,7 +718,7 @@ config: ~/.escli/config.json (600 permissions, owner-only)
718
718
  subs = root.add_subparsers(dest="command", metavar="command")
719
719
 
720
720
  # ── auth ──────────────────────────────────────────────────────────
721
- auth_p = subs.add_parser("auth", aliases=["a"], help="Authentication and profiles", formatter_class=F,
721
+ auth_p = subs.add_parser("auth", aliases=["a"], help="Manage API auth and profiles", formatter_class=F,
722
722
  epilog=f"""subcommands:
723
723
  login {DOT} authenticate with API key
724
724
  logout {DOT} remove credentials
@@ -733,7 +733,7 @@ agent examples:
733
733
  """)
734
734
  auth_subs = auth_p.add_subparsers(dest="auth_command", metavar="subcommand")
735
735
 
736
- login_p = auth_subs.add_parser("login", aliases=["l"], help="Authenticate with an API key", formatter_class=F,
736
+ login_p = auth_subs.add_parser("login", aliases=["l"], help="Authenticate via browser or API key", formatter_class=F,
737
737
  epilog=f"""examples:
738
738
  escli auth login {DOT} interactive prompt
739
739
  escli auth login --key sk-xxx {DOT} non-interactive (agents/CI)
@@ -745,24 +745,24 @@ agent: escli --json auth login --key sk-xxx
745
745
  """)
746
746
  login_p.add_argument("--key", "-k", default=None, help="API key (omit for interactive prompt)")
747
747
  login_p.add_argument("--profile", "-p", default="default", help="Profile name (default: default)")
748
- login_p.add_argument("--label", default=None, help="Human-readable label for this profile")
749
- login_p.add_argument("--endpoint", default=None, help=f"API endpoint (default: {ENDPOINT})")
748
+ login_p.add_argument("--label", default=None, help="Profile label")
749
+ login_p.add_argument("--endpoint", default=None, help="API endpoint")
750
750
  login_p.set_defaults(func=cmd_auth_login)
751
751
 
752
752
  logout_p = auth_subs.add_parser("logout", help="Remove stored credentials")
753
753
  logout_p.add_argument("--profile", "-p", default=None, help="Profile to remove (default: active)")
754
- logout_p.add_argument("--all", action="store_true", help="Remove ALL profiles")
754
+ logout_p.add_argument("--all", action="store_true", help="Remove all profiles")
755
755
  logout_p.set_defaults(func=cmd_auth_logout)
756
756
 
757
- auth_subs.add_parser("status", aliases=["s", "whoami"], help="Show current auth status").set_defaults(func=cmd_auth_status)
758
- auth_subs.add_parser("profiles", aliases=["ls", "list"], help="List all profiles").set_defaults(func=cmd_auth_profiles)
757
+ auth_subs.add_parser("status", aliases=["s", "whoami"], help="Show current profile and auth state").set_defaults(func=cmd_auth_status)
758
+ auth_subs.add_parser("profiles", aliases=["ls", "list"], help="List all stored profiles").set_defaults(func=cmd_auth_profiles)
759
759
 
760
- switch_p = auth_subs.add_parser("switch", aliases=["use"], help="Switch active profile")
760
+ switch_p = auth_subs.add_parser("switch", aliases=["use"], help="Switch the active profile")
761
761
  switch_p.add_argument("profile_name", help="Profile to activate")
762
762
  switch_p.set_defaults(func=cmd_auth_switch)
763
763
 
764
764
  # ── image ─────────────────────────────────────────────────────────
765
- image_p = subs.add_parser("image", aliases=["img", "i"], help="Image generation and editing", formatter_class=F,
765
+ image_p = subs.add_parser("image", aliases=["img", "i"], help="Generate or edit images from prompts", formatter_class=F,
766
766
  epilog=f"""subcommands:
767
767
  generate (gen, g) {DOT} generate image from text prompt
768
768
  edit (e) {DOT} edit existing image with instruction
@@ -777,29 +777,29 @@ sizes: square(1024x1024) landscape(1536x1024) portrait(1024x1536)
777
777
  image_subs = image_p.add_subparsers(dest="image_command", metavar="subcommand")
778
778
 
779
779
  def add_image_flags(p, include_input=False):
780
- p.add_argument("prompt", nargs="+", help="Text prompt describing desired image")
780
+ p.add_argument("prompt", nargs="+", help="Text prompt")
781
781
  p.add_argument("-s", "--size", default=os.environ.get("ESCLI_IMAGE_SIZE", DEFAULTS["size"]),
782
- help="Image size: square|sq|landscape|wide|ls|portrait|tall|port or WxH (default: square = 1024x1024)")
782
+ help="Size: sq|wide|tall or WxH (default: 1024x1024)")
783
783
  p.add_argument("-q", "--quality", default=os.environ.get("ESCLI_IMAGE_QUALITY", DEFAULTS["quality"]),
784
- choices=VALID_QUALITIES, help="Quality: low (fast, ~15s) | medium (~25s) | high (best, ~40s) (default: high)")
784
+ choices=VALID_QUALITIES, help="Quality: low|medium|high (default: high)")
785
785
  p.add_argument("-f", "--format", default=os.environ.get("ESCLI_IMAGE_FORMAT", DEFAULTS["format"]),
786
- choices=VALID_FORMATS, help="Output format: png | jpeg | jpg | webp (default: png)")
786
+ choices=VALID_FORMATS, help="Format: png|jpeg|webp (default: png)")
787
787
  p.add_argument("-m", "--model", default=os.environ.get("ESCLI_IMAGE_MODEL", DEFAULTS["model"]),
788
- help="Model to use (default: gpt-image-2)")
788
+ help="Model (default: gpt-image-2)")
789
789
  p.add_argument("-o", "--output", default=None,
790
- help="Output file path. If omitted, auto-generates: YYYYMMDD-HHMMSS-slugified-prompt.png")
790
+ help="Output path (default: auto-named in --out-dir)")
791
791
  p.add_argument("-d", "--out-dir", default=os.environ.get("ESCLI_OUT_DIR", DEFAULTS["out_dir"]),
792
- help="Output directory when -o not set (default: current dir)")
793
- p.add_argument("--open", action="store_true", help="Open image in default viewer after generating")
792
+ help="Output dir (default: .)")
793
+ p.add_argument("--open", action="store_true", help="Open result after writing")
794
794
  p.add_argument("--compression", type=int, default=None, metavar="0-100",
795
- help="Output compression level for jpeg/webp (0=min, 100=max)")
795
+ help="JPEG/WebP compression 0-100")
796
796
  p.add_argument("--moderation", default=None, choices=["auto", "low"],
797
- help="Content moderation: auto (default) | low (permissive)")
797
+ help="Moderation: auto|low")
798
798
  if include_input:
799
799
  p.add_argument("-i", "--input", required=True,
800
- help="Path to input image file to edit (png, jpg, webp)")
800
+ help="Input image (png/jpg/webp)")
801
801
  p.add_argument("--fidelity", default=None, choices=["low", "high"],
802
- help="How closely to preserve the input image: low (more creative) | high (more faithful)")
802
+ help="Fidelity to input: low|high")
803
803
 
804
804
  gen_p = image_subs.add_parser("generate", aliases=["gen", "g"], help="Generate an image from a text prompt",
805
805
  formatter_class=F, epilog=f"""sizes (any of these work for -s):
@@ -834,7 +834,7 @@ agent examples:
834
834
  """)
835
835
  add_image_flags(gen_p)
836
836
  gen_p.add_argument("--background", default=None, choices=VALID_BACKGROUNDS,
837
- help="Background: auto (default) | transparent (removes background)")
837
+ help="Background: auto|transparent")
838
838
  gen_p.set_defaults(func=cmd_image_generate)
839
839
 
840
840
  edit_p = image_subs.add_parser("edit", aliases=["e"], help="Edit an existing image with an instruction",
@@ -863,7 +863,7 @@ supported input formats: png, jpg, jpeg, webp
863
863
  edit_p.set_defaults(func=cmd_image_edit)
864
864
 
865
865
  # ── models ────────────────────────────────────────────────────────
866
- subs.add_parser("models", aliases=["m"], help="List available models", formatter_class=F,
866
+ subs.add_parser("models", aliases=["m"], help="List models available on the endpoint", formatter_class=F,
867
867
  epilog=f"""lists all models available on the API endpoint.
868
868
 
869
869
  agent example:
@@ -896,7 +896,7 @@ agent example:
896
896
  register_usage(subs)
897
897
 
898
898
  # ── version ───────────────────────────────────────────────────────
899
- subs.add_parser("version", help="Show version info").set_defaults(func=cmd_version)
899
+ subs.add_parser("version", help="Print escli version").set_defaults(func=cmd_version)
900
900
 
901
901
  return root
902
902
 
@@ -394,7 +394,7 @@ def register(subparsers):
394
394
  F = argparse.RawDescriptionHelpFormatter
395
395
 
396
396
  audio_p = subparsers.add_parser(
397
- "audio", aliases=["au"], help="Audio transcription (AssemblyAI)",
397
+ "audio", aliases=["au"], help="Transcribe audio files or URLs",
398
398
  formatter_class=F,
399
399
  epilog="""subcommands:
400
400
  transcribe <file-or-url> Transcribe audio with speaker diarization
@@ -413,42 +413,42 @@ examples:
413
413
  audio_subs = audio_p.add_subparsers(dest="audio_command", metavar="subcommand")
414
414
 
415
415
  # transcribe
416
- tr_p = audio_subs.add_parser("transcribe", aliases=["t"], help="Transcribe audio")
416
+ tr_p = audio_subs.add_parser("transcribe", aliases=["t"], help="Transcribe an audio file or URL")
417
417
  tr_p.add_argument("source", help="Audio file path or URL")
418
- tr_p.add_argument("-o", "--output", default=None, help="Write output to file")
419
- tr_p.add_argument("--format", choices=["text", "json", "srt", "vtt"], default="text", help="Output format")
418
+ tr_p.add_argument("-o", "--output", default=None, help="Output file")
419
+ tr_p.add_argument("--format", choices=["text", "json", "srt", "vtt"], default="text", help="Output format: text|json|srt|vtt")
420
420
  # Speaker diarization
421
- tr_p.add_argument("--speakers", action="store_true", help="Enable speaker labels")
421
+ tr_p.add_argument("--speakers", action="store_true", help="Speaker diarization")
422
422
  tr_p.add_argument("--speakers-expected", type=int, default=None, metavar="N", help="Expected speaker count (1-20)")
423
- tr_p.add_argument("--speaker-names", default=None, metavar="A,B,C", help="Identify speakers by name")
423
+ tr_p.add_argument("--speaker-names", default=None, metavar="A,B,C", help="Identify speakers by name (comma-separated)")
424
424
  # Audio intelligence
425
- tr_p.add_argument("--sentiment", action="store_true", help="Enable sentiment analysis")
426
- tr_p.add_argument("--chapters", action="store_true", help="Enable auto chapters")
427
- tr_p.add_argument("--entities", action="store_true", help="Enable entity detection")
428
- tr_p.add_argument("--summarize", action="store_true", help="Enable summarization")
429
- tr_p.add_argument("--highlights", action="store_true", help="Enable auto highlights")
430
- tr_p.add_argument("--topics", action="store_true", help="Enable topic detection (IAB)")
431
- tr_p.add_argument("--content-safety", action="store_true", help="Enable content safety detection")
425
+ tr_p.add_argument("--sentiment", action="store_true", help="Sentiment analysis")
426
+ tr_p.add_argument("--chapters", action="store_true", help="Auto chapters")
427
+ tr_p.add_argument("--entities", action="store_true", help="Entity detection")
428
+ tr_p.add_argument("--summarize", action="store_true", help="Summarization")
429
+ tr_p.add_argument("--highlights", action="store_true", help="Auto highlights")
430
+ tr_p.add_argument("--topics", action="store_true", help="IAB topic detection")
431
+ tr_p.add_argument("--content-safety", action="store_true", help="Content safety detection")
432
432
  # Transcription options
433
433
  tr_p.add_argument("--language", default=None, metavar="CODE", help="Language code (default: auto-detect)")
434
434
  tr_p.add_argument("--dual-channel", action="store_true", help="Dual channel transcription")
435
435
  tr_p.add_argument("--multichannel", action="store_true", help="Multichannel transcription")
436
- tr_p.add_argument("--word-boost", default=None, metavar="W1,W2", help="Boost accuracy for words")
436
+ tr_p.add_argument("--word-boost", default=None, metavar="W1,W2", help="Boost accuracy for words (comma-separated)")
437
437
  tr_p.add_argument("--disfluencies", action="store_true", help="Include filler words")
438
438
  tr_p.add_argument("--filter-profanity", action="store_true", help="Filter profanity")
439
439
  tr_p.add_argument("--redact-pii", action="store_true", help="Redact PII")
440
440
  tr_p.set_defaults(func=cmd_transcribe)
441
441
 
442
442
  # status
443
- st_p = audio_subs.add_parser("status", aliases=["s"], help="Check transcript status")
443
+ st_p = audio_subs.add_parser("status", aliases=["s"], help="Check transcript job status")
444
444
  st_p.add_argument("transcript_id", help="Transcript ID")
445
445
  st_p.set_defaults(func=cmd_status)
446
446
 
447
447
  # get
448
- get_p = audio_subs.add_parser("get", aliases=["g"], help="Fetch completed transcript")
448
+ get_p = audio_subs.add_parser("get", aliases=["g"], help="Fetch a completed transcript")
449
449
  get_p.add_argument("transcript_id", help="Transcript ID")
450
- get_p.add_argument("--format", choices=["text", "json", "srt", "vtt"], default="text", help="Output format")
451
- get_p.add_argument("-o", "--output", default=None, help="Write output to file")
450
+ get_p.add_argument("--format", choices=["text", "json", "srt", "vtt"], default="text", help="Output format: text|json|srt|vtt")
451
+ get_p.add_argument("-o", "--output", default=None, help="Output file")
452
452
  get_p.set_defaults(func=cmd_get)
453
453
 
454
454
  # list
@@ -333,7 +333,7 @@ def register(subparsers):
333
333
  F = argparse.RawDescriptionHelpFormatter
334
334
 
335
335
  docs_p = subparsers.add_parser(
336
- "docs", aliases=["d"], help="Library documentation search (Context7)",
336
+ "docs", aliases=["d"], help="Fetch up-to-date library docs",
337
337
  formatter_class=F,
338
338
  epilog="""subcommands:
339
339
  search <library> Search for libraries
@@ -350,30 +350,30 @@ examples:
350
350
  docs_subs = docs_p.add_subparsers(dest="docs_command", metavar="subcommand")
351
351
 
352
352
  # search
353
- search_p = docs_subs.add_parser("search", aliases=["s"], help="Search for libraries")
354
- search_p.add_argument("library_name", help="Library name to search for")
353
+ search_p = docs_subs.add_parser("search", aliases=["s"], help="Search for libraries by name")
354
+ search_p.add_argument("library_name", help="Library name")
355
355
  search_p.add_argument("query", nargs="?", default=None, help="Optional search query")
356
- search_p.add_argument("--limit", type=int, default=10, help="Limit results (default: 10)")
356
+ search_p.add_argument("--limit", type=int, default=10, help="Max results (default: 10)")
357
357
  search_p.add_argument("--refresh", action="store_true", help="Bypass cache")
358
358
  search_p.set_defaults(func=cmd_search)
359
359
 
360
360
  # get (one-shot: resolve + fetch)
361
- get_p = docs_subs.add_parser("get", aliases=["g"], help="Resolve + fetch docs")
361
+ get_p = docs_subs.add_parser("get", aliases=["g"], help="Resolve a library and fetch docs in one shot")
362
362
  get_p.add_argument("library_name", help="Library name")
363
363
  get_p.add_argument("query", help="Documentation query")
364
364
  get_p.add_argument("--tokens", type=int, default=None, help="Max tokens to return")
365
365
  get_p.add_argument("--page", type=int, default=None, help="Page number (1-10)")
366
- get_p.add_argument("--topic", default=None, help="Focus on specific topic")
366
+ get_p.add_argument("--topic", default=None, help="Focus on a topic")
367
367
  get_p.add_argument("--refresh", action="store_true", help="Bypass cache")
368
368
  get_p.set_defaults(func=cmd_get)
369
369
 
370
370
  # fetch (by library ID)
371
- fetch_p = docs_subs.add_parser("fetch", aliases=["f"], help="Fetch docs by library ID")
371
+ fetch_p = docs_subs.add_parser("fetch", aliases=["f"], help="Fetch docs by exact library ID")
372
372
  fetch_p.add_argument("library_id", help="Library ID (e.g. /facebook/react)")
373
373
  fetch_p.add_argument("query", help="Documentation query")
374
374
  fetch_p.add_argument("--tokens", type=int, default=None, help="Max tokens to return")
375
375
  fetch_p.add_argument("--page", type=int, default=None, help="Page number (1-10)")
376
- fetch_p.add_argument("--topic", default=None, help="Focus on specific topic")
376
+ fetch_p.add_argument("--topic", default=None, help="Focus on a topic")
377
377
  fetch_p.add_argument("--refresh", action="store_true", help="Bypass cache")
378
378
  fetch_p.set_defaults(func=cmd_fetch)
379
379
 
@@ -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)
@@ -554,7 +700,7 @@ def register(subparsers):
554
700
  F = argparse.RawDescriptionHelpFormatter
555
701
 
556
702
  p = subparsers.add_parser(
557
- "research", aliases=["r"], help="Web research tasks (Parallel Task API)",
703
+ "research", aliases=["r"], help="Run deep web research tasks",
558
704
  formatter_class=F,
559
705
  epilog="""modes:
560
706
  escli research "query" -o report.md Run a research task (default)
@@ -591,31 +737,31 @@ output modes:
591
737
  mode_g.add_argument("--result", default=None, metavar="RUN_ID", help="Fetch completed task result")
592
738
 
593
739
  # Run options
594
- p.add_argument("-o", "--output", default=None, help="Output file path (markdown or JSON)")
740
+ p.add_argument("-o", "--output", default=None, help="Output path (md or json)")
595
741
  p.add_argument("-p", "--processor", default="pro", choices=PROCESSORS, help="Processor tier (default: pro)")
596
742
 
597
743
  # Output schema
598
744
  schema_g = p.add_argument_group("output schema")
599
- schema_g.add_argument("--text", action="store_true", help="Markdown report format")
745
+ schema_g.add_argument("--text", action="store_true", help="Markdown report output")
600
746
  schema_g.add_argument("--schema", default=None, metavar="FILE", help="JSON Schema file for structured output")
601
- schema_g.add_argument("--output-schema", default=None, metavar="STR", help="Inline output schema description")
747
+ schema_g.add_argument("--output-schema", default=None, metavar="STR", help="Inline schema description")
602
748
 
603
749
  # Input
604
750
  input_g = p.add_argument_group("input")
605
- input_g.add_argument("--input-json", default=None, metavar="JSON", help="JSON object as input (instead of text)")
606
- input_g.add_argument("--input-file", default=None, metavar="FILE", help="JSON file as input")
751
+ input_g.add_argument("--input-json", default=None, metavar="JSON", help="JSON input (instead of text)")
752
+ input_g.add_argument("--input-file", default=None, metavar="FILE", help="JSON input file")
607
753
 
608
754
  # Source policy
609
755
  source_g = p.add_argument_group("source policy")
610
756
  source_g.add_argument("--include-domains", default=None, metavar="D1,D2", help="Only use these domains")
611
757
  source_g.add_argument("--exclude-domains", default=None, metavar="D1,D2", help="Exclude these domains")
612
- source_g.add_argument("--after-date", default=None, metavar="YYYY-MM-DD", help="Only content after this date")
758
+ source_g.add_argument("--after-date", default=None, metavar="YYYY-MM-DD", help="Filter content after this date")
613
759
 
614
760
  # Advanced
615
761
  adv_g = p.add_argument_group("advanced")
616
- adv_g.add_argument("--location", default=None, metavar="CC", help="ISO country code for geo-targeted results")
762
+ adv_g.add_argument("--location", default=None, metavar="CC", help="ISO country code for geo-targeting")
617
763
  adv_g.add_argument("--metadata", default=None, help="Metadata as JSON or key=val,key=val")
618
- adv_g.add_argument("--follow-up", default=None, metavar="RUN_ID", help="Follow-up on a previous task run")
764
+ adv_g.add_argument("--follow-up", default=None, metavar="RUN_ID", help="Follow up on a previous run")
619
765
  adv_g.add_argument("--no-basis", action="store_true", help="Exclude citations and reasoning")
620
766
 
621
767
  p.set_defaults(func=_dispatch)
@@ -268,7 +268,7 @@ def register(subparsers):
268
268
 
269
269
  # search
270
270
  search_p = subparsers.add_parser(
271
- "search", aliases=["s"], help="Web search (Parallel.ai)",
271
+ "search", aliases=["s"], help="Search the web, return ranked excerpts",
272
272
  formatter_class=F,
273
273
  epilog="""examples:
274
274
  escli search "latest cloudflare workers features"
@@ -278,14 +278,14 @@ def register(subparsers):
278
278
  Uses the free Parallel Search MCP. No API key needed.
279
279
  Add a Parallel API key for higher rate limits.
280
280
  """)
281
- search_p.add_argument("objective", nargs="+", help="What you're searching for")
281
+ search_p.add_argument("objective", nargs="+", help="Search objective")
282
282
  search_p.add_argument("--queries", nargs="+", default=None, metavar="Q",
283
- help="Specific search queries (default: uses objective)")
283
+ help="Override queries (default: objective)")
284
284
  search_p.set_defaults(func=cmd_search)
285
285
 
286
286
  # fetch
287
287
  fetch_p = subparsers.add_parser(
288
- "fetch", aliases=["f"], help="Extract URL content (Parallel.ai)",
288
+ "fetch", aliases=["f"], help="Extract clean content from URLs",
289
289
  formatter_class=F,
290
290
  epilog="""examples:
291
291
  escli fetch https://docs.parallel.ai/search/search-quickstart
@@ -295,9 +295,9 @@ Add a Parallel API key for higher rate limits.
295
295
 
296
296
  Uses the free Parallel Extract MCP. No API key needed.
297
297
  """)
298
- fetch_p.add_argument("urls", nargs="+", help="URLs to extract content from")
299
- fetch_p.add_argument("--objective", default=None, help="Focus extraction on this topic")
300
- fetch_p.add_argument("--full", action="store_true", help="Return full page content instead of excerpts")
298
+ fetch_p.add_argument("urls", nargs="+", help="URLs to extract")
299
+ fetch_p.add_argument("--objective", default=None, help="Focus on this topic")
300
+ fetch_p.add_argument("--full", action="store_true", help="Return full content (not excerpts)")
301
301
  fetch_p.set_defaults(func=cmd_fetch)
302
302
 
303
303
  return search_p
@@ -215,7 +215,7 @@ def register(subparsers):
215
215
  F = argparse.RawDescriptionHelpFormatter
216
216
 
217
217
  p = subparsers.add_parser(
218
- "social", help="Social media search (Tavily)",
218
+ "social", help="Search posts across social platforms",
219
219
  formatter_class=F,
220
220
  epilog="""platforms:
221
221
  reddit, x, tiktok, linkedin, instagram, facebook, youtube, combined (default)
@@ -235,11 +235,11 @@ time ranges:
235
235
  year Last 12 months
236
236
  """)
237
237
 
238
- p.add_argument("query", nargs="+", help="What to search for on social media")
238
+ p.add_argument("query", nargs="+", help="Search query")
239
239
  p.add_argument("--platform", "-p", default=None, metavar="P",
240
- help=f"Platform(s) to search, comma-separated ({', '.join(VALID_PLATFORMS)})")
240
+ help=f"Platform(s), comma-separated ({', '.join(VALID_PLATFORMS)})")
241
241
  p.add_argument("--time", "-t", default=None, choices=VALID_TIME_RANGES, metavar="T",
242
- help="Time range: day, week, month, year")
242
+ help="Time range: day|week|month|year")
243
243
  p.add_argument("--max-results", "-n", type=int, default=10, metavar="N",
244
244
  help="Max results (1-20, default: 10)")
245
245
  p.add_argument("--depth", default="advanced", choices=VALID_DEPTHS,
@@ -247,11 +247,11 @@ time ranges:
247
247
  p.add_argument("--answer", "-a", action="store_true",
248
248
  help="Include AI-synthesized answer")
249
249
  p.add_argument("--raw", action="store_true",
250
- help="Include full post content (raw markdown)")
250
+ help="Include full post content")
251
251
  p.add_argument("--images", action="store_true",
252
- help="Include images from results")
252
+ help="Include images")
253
253
  p.add_argument("--country", default=None, metavar="CC",
254
- help="Country for geo-targeted results (e.g. 'united states')")
254
+ help="Country for geo-targeting (e.g. 'united states')")
255
255
  p.set_defaults(func=cmd_social)
256
256
 
257
257
  return p
@@ -390,7 +390,7 @@ def register(subparsers):
390
390
  F = argparse.RawDescriptionHelpFormatter
391
391
 
392
392
  p = subparsers.add_parser(
393
- "usage", aliases=["u"], help="API usage and spend analytics",
393
+ "usage", aliases=["u"], help="Show API usage and spend",
394
394
  formatter_class=F,
395
395
  epilog=f"""examples:
396
396
  escli usage {DOT} summary, last 30 days
@@ -418,11 +418,11 @@ auth: requires `escli auth login` (uses CLI token, not API key).
418
418
  p.add_argument("--days", type=int, default=30, metavar="N",
419
419
  help="Window size in days (default: 30)")
420
420
  p.add_argument("--service", default=None, metavar="NAME",
421
- help="Filter to a single service (e.g. parallel, openai, assemblyai)")
421
+ help="Filter to one service (parallel|openai|assemblyai|...)")
422
422
  p.add_argument("--daily", action="store_true",
423
- help="Show day-by-day breakdown")
423
+ help="Day-by-day breakdown")
424
424
  p.add_argument("--pricing", action="store_true",
425
- help="Show current pricing rules (ignores --days/--service)")
425
+ help="Show current pricing rules")
426
426
  p.set_defaults(func=cmd_usage)
427
427
 
428
428
  return p
File without changes
File without changes
File without changes