eightstatecli 0.4.0__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.0 → eightstatecli-0.4.2}/PKG-INFO +1 -1
- {eightstatecli-0.4.0 → eightstatecli-0.4.2}/pyproject.toml +1 -1
- {eightstatecli-0.4.0 → eightstatecli-0.4.2}/src/escli/__init__.py +143 -38
- {eightstatecli-0.4.0 → eightstatecli-0.4.2}/src/escli/commands/audio.py +24 -4
- {eightstatecli-0.4.0 → eightstatecli-0.4.2}/src/escli/commands/docs.py +40 -14
- {eightstatecli-0.4.0 → eightstatecli-0.4.2}/src/escli/commands/research.py +198 -9
- {eightstatecli-0.4.0 → eightstatecli-0.4.2}/src/escli/commands/search.py +41 -24
- {eightstatecli-0.4.0 → eightstatecli-0.4.2}/src/escli/commands/social.py +19 -5
- eightstatecli-0.4.2/src/escli/services/credentials.py +215 -0
- eightstatecli-0.4.0/src/escli/services/credentials.py +0 -117
- {eightstatecli-0.4.0 → eightstatecli-0.4.2}/.gitignore +0 -0
- {eightstatecli-0.4.0 → eightstatecli-0.4.2}/LICENSE +0 -0
- {eightstatecli-0.4.0 → eightstatecli-0.4.2}/README.md +0 -0
- {eightstatecli-0.4.0 → eightstatecli-0.4.2}/src/escli/__main__.py +0 -0
- {eightstatecli-0.4.0 → eightstatecli-0.4.2}/src/escli/commands/__init__.py +0 -0
- {eightstatecli-0.4.0 → eightstatecli-0.4.2}/src/escli/commands/usage.py +0 -0
- {eightstatecli-0.4.0 → eightstatecli-0.4.2}/src/escli/services/__init__.py +0 -0
- {eightstatecli-0.4.0 → eightstatecli-0.4.2}/src/escli/services/describe.py +0 -0
- {eightstatecli-0.4.0 → 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
|
|
@@ -17,7 +17,7 @@ AGENT INSTRUCTIONS:
|
|
|
17
17
|
- Exit codes: 0=success, 1=error, 2=usage
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
|
-
__version__ = "0.4.
|
|
20
|
+
__version__ = "0.4.1"
|
|
21
21
|
|
|
22
22
|
import argparse
|
|
23
23
|
import base64
|
|
@@ -111,6 +111,7 @@ def get_profile(name: str = None) -> dict:
|
|
|
111
111
|
return {
|
|
112
112
|
"name": name,
|
|
113
113
|
"api_key": profile.get("api_key") or os.environ.get("ESCLI_API_KEY", ""),
|
|
114
|
+
"cli_token": profile.get("cli_token", ""),
|
|
114
115
|
"endpoint": profile.get("endpoint") or os.environ.get("ESCLI_BASE_URL", ENDPOINT),
|
|
115
116
|
"label": profile.get("label", name),
|
|
116
117
|
}
|
|
@@ -142,12 +143,24 @@ def delete_profile(name):
|
|
|
142
143
|
def list_profiles():
|
|
143
144
|
cfg = _load_config()
|
|
144
145
|
active = cfg.get("active_profile", "default")
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
146
|
+
result = []
|
|
147
|
+
for n, d in cfg.get("profiles", {}).items():
|
|
148
|
+
api_key = d.get("api_key", "")
|
|
149
|
+
cli_token = d.get("cli_token", "")
|
|
150
|
+
if api_key and len(api_key) > 12:
|
|
151
|
+
key_display = f"{api_key[:8]}...{api_key[-4:]}"
|
|
152
|
+
elif cli_token:
|
|
153
|
+
key_display = "gate"
|
|
154
|
+
else:
|
|
155
|
+
key_display = "***"
|
|
156
|
+
result.append({
|
|
157
|
+
"name": n, "label": d.get("label", n),
|
|
158
|
+
"key": key_display,
|
|
159
|
+
"auth_type": "gate" if cli_token else "api_key",
|
|
160
|
+
"endpoint": d.get("endpoint", ENDPOINT),
|
|
161
|
+
"active": n == active, "created": d.get("created", ""),
|
|
162
|
+
})
|
|
163
|
+
return result
|
|
151
164
|
|
|
152
165
|
|
|
153
166
|
def resolve_api_key(args):
|
|
@@ -212,8 +225,15 @@ def open_file(path):
|
|
|
212
225
|
elif sys.platform == "win32":
|
|
213
226
|
os.startfile(str(path))
|
|
214
227
|
|
|
215
|
-
def ensure_authed(args):
|
|
228
|
+
def ensure_authed(args, service: str = "image"):
|
|
216
229
|
api_key, base_url = resolve_api_key(args), resolve_endpoint(args)
|
|
230
|
+
if not api_key:
|
|
231
|
+
# Try credential vending via gate (device-flow auth stores cli_token, not api_key)
|
|
232
|
+
from .services.credentials import vend_key
|
|
233
|
+
result = vend_key(service)
|
|
234
|
+
if result:
|
|
235
|
+
api_key = result["api_key"]
|
|
236
|
+
base_url = result.get("base_url", ENDPOINT)
|
|
217
237
|
if not api_key:
|
|
218
238
|
log(f"\n {CROSS} not authenticated\n {ARROW} run: escli auth login\n", getattr(args, 'quiet', False))
|
|
219
239
|
sys.exit(1)
|
|
@@ -370,16 +390,25 @@ def cmd_auth_logout(args):
|
|
|
370
390
|
def cmd_auth_status(args):
|
|
371
391
|
profile = get_profile()
|
|
372
392
|
has_key = bool(profile["api_key"])
|
|
393
|
+
has_token = bool(profile["cli_token"])
|
|
394
|
+
authenticated = has_key or has_token
|
|
373
395
|
if args.json:
|
|
374
|
-
r = {"authenticated":
|
|
396
|
+
r = {"authenticated": authenticated, "profile": profile["name"],
|
|
397
|
+
"auth_type": "gate" if has_token else "api_key",
|
|
398
|
+
"endpoint": profile["endpoint"],
|
|
375
399
|
"config_path": str(CONFIG_FILE)}
|
|
376
400
|
if has_key: r["key"] = mask_key(profile["api_key"])
|
|
377
401
|
print(jsonlib.dumps(r))
|
|
378
402
|
else:
|
|
379
|
-
if
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
403
|
+
if authenticated:
|
|
404
|
+
lines = [f"profile {profile['name']}"]
|
|
405
|
+
if has_token:
|
|
406
|
+
lines.append(f"auth gate (credential vending)")
|
|
407
|
+
lines.append(f"gate {profile['endpoint']}")
|
|
408
|
+
else:
|
|
409
|
+
lines.append(f"key {mask_key(profile['api_key'])}")
|
|
410
|
+
lines.append(f"endpoint {profile['endpoint']}")
|
|
411
|
+
print(box(f"{CHECK} authenticated", lines))
|
|
383
412
|
else: print(f"\n {CROSS} not authenticated\n {ARROW} run: escli auth login\n")
|
|
384
413
|
return 0
|
|
385
414
|
|
|
@@ -412,9 +441,9 @@ def cmd_auth_switch(args):
|
|
|
412
441
|
|
|
413
442
|
|
|
414
443
|
def cmd_image_generate(args):
|
|
415
|
-
|
|
444
|
+
from .services.credentials import call_with_retry, ServiceCallError, VendedKey
|
|
445
|
+
|
|
416
446
|
OpenAI = require_openai()
|
|
417
|
-
client = OpenAI(base_url=base_url, api_key=api_key, timeout=args.timeout)
|
|
418
447
|
prompt = " ".join(args.prompt)
|
|
419
448
|
if not prompt: print(f" {CROSS} prompt required", file=sys.stderr); sys.exit(1)
|
|
420
449
|
|
|
@@ -438,15 +467,38 @@ def cmd_image_generate(args):
|
|
|
438
467
|
if args.compression is not None: extra_body["output_compression"] = args.compression
|
|
439
468
|
if args.moderation: extra_body["moderation"] = args.moderation
|
|
440
469
|
|
|
441
|
-
|
|
442
|
-
|
|
470
|
+
def do_generate(key):
|
|
471
|
+
base_url = key.base_url or ENDPOINT
|
|
472
|
+
client = OpenAI(base_url=base_url, api_key=key.api_key, timeout=args.timeout)
|
|
443
473
|
kwargs = dict(model=args.model, prompt=prompt, size=size, quality=args.quality, response_format="b64_json")
|
|
444
474
|
if extra_body: kwargs["extra_body"] = extra_body
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
475
|
+
return client.images.generate(**kwargs)
|
|
476
|
+
|
|
477
|
+
t0 = time.time()
|
|
478
|
+
|
|
479
|
+
# Try explicit key first, then gate-vended keys with retry
|
|
480
|
+
explicit_key = resolve_api_key(args)
|
|
481
|
+
if explicit_key:
|
|
482
|
+
try:
|
|
483
|
+
resp = do_generate(VendedKey(explicit_key, "explicit", resolve_endpoint(args)))
|
|
484
|
+
except Exception as e:
|
|
485
|
+
if args.json: print(jsonlib.dumps({"error": str(e), "success": False}))
|
|
486
|
+
else: log(f" {CROSS} {e}", args.quiet)
|
|
487
|
+
sys.exit(1)
|
|
488
|
+
else:
|
|
489
|
+
try:
|
|
490
|
+
resp = call_with_retry("image", do_generate)
|
|
491
|
+
except ServiceCallError as e:
|
|
492
|
+
if args.json: print(jsonlib.dumps({"error": str(e), "success": False}))
|
|
493
|
+
else: log(f" {CROSS} all keys exhausted: {e}", args.quiet)
|
|
494
|
+
sys.exit(1)
|
|
495
|
+
except Exception as e:
|
|
496
|
+
if args.json: print(jsonlib.dumps({"error": str(e), "success": False}))
|
|
497
|
+
else: log(f" {CROSS} {e}", args.quiet)
|
|
498
|
+
sys.exit(1)
|
|
499
|
+
if resp is None:
|
|
500
|
+
log(f"\n {CROSS} not authenticated\n {ARROW} run: escli auth login\n", args.quiet)
|
|
501
|
+
sys.exit(1)
|
|
450
502
|
|
|
451
503
|
elapsed = round(time.time() - t0, 1)
|
|
452
504
|
raw = base64.b64decode(resp.data[0].b64_json)
|
|
@@ -474,7 +526,8 @@ def cmd_image_generate(args):
|
|
|
474
526
|
|
|
475
527
|
|
|
476
528
|
def cmd_image_edit(args):
|
|
477
|
-
|
|
529
|
+
from .services.credentials import call_with_retry, ServiceCallError, VendedKey
|
|
530
|
+
|
|
478
531
|
httpx = require_httpx()
|
|
479
532
|
prompt = " ".join(args.prompt)
|
|
480
533
|
if not prompt: print(f" {CROSS} prompt required", file=sys.stderr); sys.exit(1)
|
|
@@ -506,16 +559,38 @@ def cmd_image_edit(args):
|
|
|
506
559
|
if args.compression is not None: body["output_compression"] = args.compression
|
|
507
560
|
if args.fidelity: body["input_fidelity"] = args.fidelity
|
|
508
561
|
|
|
509
|
-
|
|
510
|
-
|
|
562
|
+
def do_edit(key):
|
|
563
|
+
base_url = key.base_url or ENDPOINT
|
|
511
564
|
r = httpx.post(f"{base_url}/images/edits",
|
|
512
|
-
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
|
565
|
+
headers={"Authorization": f"Bearer {key.api_key}", "Content-Type": "application/json"},
|
|
513
566
|
json=body, timeout=args.timeout)
|
|
514
|
-
r.raise_for_status()
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
567
|
+
r.raise_for_status()
|
|
568
|
+
return r.json()
|
|
569
|
+
|
|
570
|
+
t0 = time.time()
|
|
571
|
+
|
|
572
|
+
explicit_key = resolve_api_key(args)
|
|
573
|
+
if explicit_key:
|
|
574
|
+
try:
|
|
575
|
+
data = do_edit(VendedKey(explicit_key, "explicit", resolve_endpoint(args)))
|
|
576
|
+
except Exception as e:
|
|
577
|
+
if args.json: print(jsonlib.dumps({"error": str(e), "success": False}))
|
|
578
|
+
else: log(f" {CROSS} {e}", args.quiet)
|
|
579
|
+
sys.exit(1)
|
|
580
|
+
else:
|
|
581
|
+
try:
|
|
582
|
+
data = call_with_retry("image", do_edit)
|
|
583
|
+
except ServiceCallError as e:
|
|
584
|
+
if args.json: print(jsonlib.dumps({"error": str(e), "success": False}))
|
|
585
|
+
else: log(f" {CROSS} all keys exhausted: {e}", args.quiet)
|
|
586
|
+
sys.exit(1)
|
|
587
|
+
except Exception as e:
|
|
588
|
+
if args.json: print(jsonlib.dumps({"error": str(e), "success": False}))
|
|
589
|
+
else: log(f" {CROSS} {e}", args.quiet)
|
|
590
|
+
sys.exit(1)
|
|
591
|
+
if data is None:
|
|
592
|
+
log(f"\n {CROSS} not authenticated\n {ARROW} run: escli auth login\n", args.quiet)
|
|
593
|
+
sys.exit(1)
|
|
519
594
|
|
|
520
595
|
elapsed = round(time.time() - t0, 1)
|
|
521
596
|
raw = base64.b64decode(data["data"][0]["b64_json"])
|
|
@@ -536,14 +611,37 @@ def cmd_image_edit(args):
|
|
|
536
611
|
|
|
537
612
|
|
|
538
613
|
def cmd_models(args):
|
|
539
|
-
|
|
614
|
+
from .services.credentials import call_with_retry, ServiceCallError, VendedKey
|
|
615
|
+
|
|
540
616
|
OpenAI = require_openai()
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
617
|
+
|
|
618
|
+
def do_list(key):
|
|
619
|
+
base_url = key.base_url or ENDPOINT
|
|
620
|
+
return OpenAI(base_url=base_url, api_key=key.api_key, timeout=30).models.list()
|
|
621
|
+
|
|
622
|
+
explicit_key = resolve_api_key(args)
|
|
623
|
+
if explicit_key:
|
|
624
|
+
try:
|
|
625
|
+
models = do_list(VendedKey(explicit_key, "explicit", resolve_endpoint(args)))
|
|
626
|
+
except Exception as e:
|
|
627
|
+
if args.json: print(jsonlib.dumps({"error": str(e), "success": False}))
|
|
628
|
+
else: print(f" {CROSS} {e}", file=sys.stderr)
|
|
629
|
+
sys.exit(1)
|
|
630
|
+
else:
|
|
631
|
+
try:
|
|
632
|
+
models = call_with_retry("image", do_list)
|
|
633
|
+
except ServiceCallError as e:
|
|
634
|
+
if args.json: print(jsonlib.dumps({"error": str(e), "success": False}))
|
|
635
|
+
else: print(f" {CROSS} all keys exhausted: {e}", file=sys.stderr)
|
|
636
|
+
sys.exit(1)
|
|
637
|
+
except Exception as e:
|
|
638
|
+
if args.json: print(jsonlib.dumps({"error": str(e), "success": False}))
|
|
639
|
+
else: print(f" {CROSS} {e}", file=sys.stderr)
|
|
640
|
+
sys.exit(1)
|
|
641
|
+
if models is None:
|
|
642
|
+
print(f"\n {CROSS} not authenticated\n {ARROW} run: escli auth login\n", file=sys.stderr)
|
|
643
|
+
sys.exit(1)
|
|
644
|
+
|
|
547
645
|
model_list = sorted([m.id for m in models.data])
|
|
548
646
|
if args.json:
|
|
549
647
|
print(jsonlib.dumps({"success": True, "models": model_list, "count": len(model_list)}))
|
|
@@ -807,6 +905,13 @@ agent example:
|
|
|
807
905
|
# Main
|
|
808
906
|
# ─────────────────────────────────────────────────────────────────────
|
|
809
907
|
def main():
|
|
908
|
+
# Windows consoles default to cp1252 which can't encode our box-drawing/symbol chars
|
|
909
|
+
import io as _io
|
|
910
|
+
for _stream in ("stdout", "stderr"):
|
|
911
|
+
_s = getattr(sys, _stream)
|
|
912
|
+
if hasattr(_s, "buffer") and _s.encoding and _s.encoding.lower().replace("-", "") != "utf8":
|
|
913
|
+
setattr(sys, _stream, _io.TextIOWrapper(_s.buffer, encoding="utf-8", errors="replace"))
|
|
914
|
+
|
|
810
915
|
parser = build_parser()
|
|
811
916
|
args = parser.parse_args()
|
|
812
917
|
for attr in ("json", "quiet"):
|
|
@@ -42,7 +42,7 @@ import pathlib
|
|
|
42
42
|
import sys
|
|
43
43
|
import time
|
|
44
44
|
|
|
45
|
-
from ..services.credentials import get_key_for_service, report_key
|
|
45
|
+
from ..services.credentials import get_key_for_service, report_key, call_with_retry, ServiceCallError, VendedKey
|
|
46
46
|
|
|
47
47
|
AAI_BASE = "https://api.assemblyai.com"
|
|
48
48
|
POLL_INTERVAL = 3.0
|
|
@@ -243,15 +243,35 @@ def _format_output(data: dict, fmt: str, args) -> str:
|
|
|
243
243
|
|
|
244
244
|
def cmd_transcribe(args):
|
|
245
245
|
"""Upload (if local file), create transcript, poll, return result."""
|
|
246
|
-
api_key = _get_api_key()
|
|
247
246
|
source = args.source
|
|
248
247
|
t0 = time.time()
|
|
249
248
|
|
|
250
|
-
# Determine audio URL
|
|
249
|
+
# Determine audio URL — if local file, upload first with retry
|
|
251
250
|
if source.startswith("http://") or source.startswith("https://"):
|
|
252
251
|
audio_url = source
|
|
252
|
+
# Still need a key for the submit call
|
|
253
|
+
api_key = _get_api_key()
|
|
253
254
|
else:
|
|
254
|
-
|
|
255
|
+
# Upload with key rotation
|
|
256
|
+
def do_upload(key):
|
|
257
|
+
url = _upload_file(source, key.api_key, args.quiet)
|
|
258
|
+
return {"url": url, "api_key": key.api_key}
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
result = call_with_retry("assemblyai", do_upload, env_var="ASSEMBLYAI_API_KEY")
|
|
262
|
+
except ServiceCallError as e:
|
|
263
|
+
if args.json:
|
|
264
|
+
print(json.dumps({"success": False, "error": str(e)}))
|
|
265
|
+
else:
|
|
266
|
+
print(f" ✗ all keys exhausted: {e}", file=sys.stderr)
|
|
267
|
+
return 1
|
|
268
|
+
|
|
269
|
+
if result is None:
|
|
270
|
+
print(" ✗ no AssemblyAI API key. Set ASSEMBLYAI_API_KEY or add one via the dashboard.", file=sys.stderr)
|
|
271
|
+
return 1
|
|
272
|
+
|
|
273
|
+
audio_url = result["url"]
|
|
274
|
+
api_key = result["api_key"]
|
|
255
275
|
|
|
256
276
|
# Build and submit
|
|
257
277
|
body = _build_transcript_body(args, audio_url)
|
|
@@ -22,7 +22,7 @@ import pathlib
|
|
|
22
22
|
import sys
|
|
23
23
|
import time
|
|
24
24
|
|
|
25
|
-
from ..services.credentials import get_key_for_service, report_key
|
|
25
|
+
from ..services.credentials import get_key_for_service, report_key, call_with_retry, ServiceCallError
|
|
26
26
|
|
|
27
27
|
CTX7_API = "https://context7.com/api"
|
|
28
28
|
CACHE_DIR = pathlib.Path(os.environ.get("ESCLI_CACHE_DIR", pathlib.Path.home() / ".escli" / "cache"))
|
|
@@ -94,12 +94,10 @@ def _request(url: str, api_key: str | None = None) -> dict:
|
|
|
94
94
|
tier=tier,
|
|
95
95
|
)
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
sys.exit(1)
|
|
97
|
+
# Let retryable errors propagate for call_with_retry to handle
|
|
98
|
+
resp.raise_for_status()
|
|
100
99
|
if resp.status_code == 404:
|
|
101
100
|
return {"_status": 404}
|
|
102
|
-
resp.raise_for_status()
|
|
103
101
|
return resp.json()
|
|
104
102
|
|
|
105
103
|
|
|
@@ -120,9 +118,20 @@ def cmd_search(args):
|
|
|
120
118
|
_print_search(cached, args, from_cache=True)
|
|
121
119
|
return 0
|
|
122
120
|
|
|
123
|
-
api_key = _get_api_key()
|
|
124
121
|
url = f"{CTX7_API}/v2/libs/search?libraryName={urllib.parse.quote(name)}&query={urllib.parse.quote(query)}"
|
|
125
|
-
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
data = call_with_retry(
|
|
125
|
+
"context7",
|
|
126
|
+
lambda key: _request(url, key.api_key),
|
|
127
|
+
env_var="CONTEXT7_API_KEY",
|
|
128
|
+
)
|
|
129
|
+
if data is None:
|
|
130
|
+
# No key — try unauthenticated
|
|
131
|
+
data = _request(url)
|
|
132
|
+
except ServiceCallError:
|
|
133
|
+
# All keys exhausted — try unauthenticated
|
|
134
|
+
data = _request(url)
|
|
126
135
|
|
|
127
136
|
if data.get("_status") == 404:
|
|
128
137
|
if args.json:
|
|
@@ -183,9 +192,18 @@ def cmd_get(args):
|
|
|
183
192
|
library_id = cached.get("id")
|
|
184
193
|
|
|
185
194
|
if not library_id:
|
|
186
|
-
api_key = _get_api_key()
|
|
187
195
|
url = f"{CTX7_API}/v2/libs/search?libraryName={urllib.parse.quote(name)}&query={urllib.parse.quote(query)}"
|
|
188
|
-
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
data = call_with_retry(
|
|
199
|
+
"context7",
|
|
200
|
+
lambda key: _request(url, key.api_key),
|
|
201
|
+
env_var="CONTEXT7_API_KEY",
|
|
202
|
+
)
|
|
203
|
+
if data is None:
|
|
204
|
+
data = _request(url)
|
|
205
|
+
except ServiceCallError:
|
|
206
|
+
data = _request(url)
|
|
189
207
|
|
|
190
208
|
if data.get("_status") == 404:
|
|
191
209
|
if args.json:
|
|
@@ -234,7 +252,6 @@ def _fetch_docs(library_id: str, query: str, args) -> int:
|
|
|
234
252
|
_print_docs(cached, args, library_id, query, from_cache=True)
|
|
235
253
|
return 0
|
|
236
254
|
|
|
237
|
-
api_key = _get_api_key()
|
|
238
255
|
url = f"{CTX7_API}/v2/context?libraryId={urllib.parse.quote(library_id)}&query={urllib.parse.quote(query)}&type=txt"
|
|
239
256
|
|
|
240
257
|
if tokens:
|
|
@@ -244,7 +261,16 @@ def _fetch_docs(library_id: str, query: str, args) -> int:
|
|
|
244
261
|
if topic:
|
|
245
262
|
url += f"&topic={urllib.parse.quote(topic)}"
|
|
246
263
|
|
|
247
|
-
|
|
264
|
+
try:
|
|
265
|
+
resp = call_with_retry(
|
|
266
|
+
"context7",
|
|
267
|
+
lambda key: _request_raw(url, key.api_key),
|
|
268
|
+
env_var="CONTEXT7_API_KEY",
|
|
269
|
+
)
|
|
270
|
+
if resp is None:
|
|
271
|
+
resp = _request_raw(url)
|
|
272
|
+
except ServiceCallError:
|
|
273
|
+
resp = _request_raw(url)
|
|
248
274
|
|
|
249
275
|
if resp.status_code == 404:
|
|
250
276
|
if args.json:
|
|
@@ -279,9 +305,9 @@ def _request_raw(url: str, api_key: str | None = None):
|
|
|
279
305
|
reset=reset, tier=tier,
|
|
280
306
|
)
|
|
281
307
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
308
|
+
# Let retryable errors propagate for call_with_retry to handle
|
|
309
|
+
if resp.status_code not in (200, 404):
|
|
310
|
+
resp.raise_for_status()
|
|
285
311
|
|
|
286
312
|
return resp
|
|
287
313
|
|
|
@@ -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:
|
|
@@ -51,7 +129,8 @@ def _get_api_key() -> str:
|
|
|
51
129
|
|
|
52
130
|
|
|
53
131
|
def _api_request(method: str, path: str, api_key: str,
|
|
54
|
-
body: dict | None = None, extra_headers: dict | None = None
|
|
132
|
+
body: dict | None = None, extra_headers: dict | None = None,
|
|
133
|
+
raise_on_error: bool = False) -> dict:
|
|
55
134
|
url = f"{API_BASE}{path}"
|
|
56
135
|
hdrs = {"x-api-key": api_key, "Content-Type": "application/json"}
|
|
57
136
|
if extra_headers:
|
|
@@ -70,6 +149,8 @@ def _api_request(method: str, path: str, api_key: str,
|
|
|
70
149
|
err_body = {"error": {"message": str(e)}}
|
|
71
150
|
|
|
72
151
|
msg = err_body.get("error", {}).get("message", str(e))
|
|
152
|
+
if raise_on_error:
|
|
153
|
+
raise
|
|
73
154
|
if e.code == 401:
|
|
74
155
|
print(f" ✗ auth failed (401): {msg}", file=sys.stderr); sys.exit(1)
|
|
75
156
|
elif e.code == 429:
|
|
@@ -77,12 +158,31 @@ def _api_request(method: str, path: str, api_key: str,
|
|
|
77
158
|
else:
|
|
78
159
|
print(f" ✗ API error ({e.code}): {msg}", file=sys.stderr); sys.exit(2)
|
|
79
160
|
except urllib.error.URLError as e:
|
|
161
|
+
if raise_on_error:
|
|
162
|
+
raise
|
|
80
163
|
print(f" ✗ network error: {e.reason}", file=sys.stderr); sys.exit(2)
|
|
81
164
|
|
|
82
165
|
|
|
83
166
|
# ── SSE streaming ────────────────────────────────────────────────
|
|
84
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
|
+
|
|
85
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
|
+
"""
|
|
86
186
|
url = f"{API_BASE}/v1beta/tasks/runs/{run_id}/events"
|
|
87
187
|
seen = set()
|
|
88
188
|
|
|
@@ -98,20 +198,45 @@ def _stream_sse(api_key: str, run_id: str, quiet: bool = False) -> dict | None:
|
|
|
98
198
|
|
|
99
199
|
try:
|
|
100
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)
|
|
101
209
|
except Exception as e:
|
|
102
210
|
if not quiet:
|
|
103
|
-
print(f" [sse] failed: {e}", file=sys.stderr)
|
|
211
|
+
print(f" [sse] connect failed: {e}", file=sys.stderr)
|
|
104
212
|
return None
|
|
105
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
|
+
|
|
106
223
|
try:
|
|
107
224
|
for raw_line in resp:
|
|
108
225
|
line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
|
|
226
|
+
if not line:
|
|
227
|
+
continue
|
|
109
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)
|
|
110
234
|
continue
|
|
111
235
|
try:
|
|
112
236
|
event = json.loads(line[6:])
|
|
113
237
|
except json.JSONDecodeError:
|
|
114
238
|
continue
|
|
239
|
+
saw_sse_event = True
|
|
115
240
|
|
|
116
241
|
etype = event.get("type", "")
|
|
117
242
|
|
|
@@ -139,12 +264,27 @@ def _stream_sse(api_key: str, run_id: str, quiet: bool = False) -> dict | None:
|
|
|
139
264
|
sys.exit(2)
|
|
140
265
|
|
|
141
266
|
elif etype == "error":
|
|
142
|
-
|
|
267
|
+
msg = _sse_error_message(event)
|
|
268
|
+
print(f" ✗ SSE error event: {msg}", file=sys.stderr)
|
|
269
|
+
sys.exit(2)
|
|
143
270
|
except Exception:
|
|
144
271
|
pass
|
|
145
272
|
finally:
|
|
146
273
|
resp.close()
|
|
147
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
|
+
|
|
148
288
|
return None
|
|
149
289
|
|
|
150
290
|
|
|
@@ -269,7 +409,6 @@ def _render_table(lines: list[str], items: list[dict]):
|
|
|
269
409
|
|
|
270
410
|
def cmd_run(args):
|
|
271
411
|
"""Create and execute a research task."""
|
|
272
|
-
api_key = _get_api_key()
|
|
273
412
|
query = " ".join(args.query)
|
|
274
413
|
processor = args.processor
|
|
275
414
|
|
|
@@ -341,12 +480,62 @@ def cmd_run(args):
|
|
|
341
480
|
if getattr(args, "follow_up", None):
|
|
342
481
|
body["previous_interaction_id"] = args.follow_up
|
|
343
482
|
|
|
344
|
-
# Submit
|
|
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".
|
|
345
487
|
headers = {"parallel-beta": SSE_BETA}
|
|
346
|
-
|
|
488
|
+
captured: dict = {"key": None}
|
|
489
|
+
|
|
490
|
+
def submit(key):
|
|
491
|
+
captured["key"] = key
|
|
492
|
+
return _api_request("POST", "/v1/tasks/runs", key.api_key,
|
|
493
|
+
body=body, extra_headers=headers, raise_on_error=True)
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
task = call_with_retry("parallel", submit, env_var="PARALLEL_API_KEY")
|
|
497
|
+
except ServiceCallError as e:
|
|
498
|
+
if args.json:
|
|
499
|
+
print(json.dumps({"success": False, "error": str(e)}))
|
|
500
|
+
else:
|
|
501
|
+
print(f" ✗ all keys exhausted: {e}", file=sys.stderr)
|
|
502
|
+
return 1
|
|
503
|
+
except urllib.error.HTTPError as e:
|
|
504
|
+
try:
|
|
505
|
+
err_body = json.loads(e.read().decode())
|
|
506
|
+
except Exception:
|
|
507
|
+
err_body = {"error": {"message": str(e)}}
|
|
508
|
+
msg = err_body.get("error", {}).get("message", str(e))
|
|
509
|
+
if args.json:
|
|
510
|
+
print(json.dumps({"success": False, "error": msg}))
|
|
511
|
+
else:
|
|
512
|
+
print(f" ✗ API error ({e.code}): {msg}", file=sys.stderr)
|
|
513
|
+
return 1
|
|
514
|
+
except Exception as e:
|
|
515
|
+
if args.json:
|
|
516
|
+
print(json.dumps({"success": False, "error": str(e)}))
|
|
517
|
+
else:
|
|
518
|
+
print(f" ✗ {e}", file=sys.stderr)
|
|
519
|
+
return 1
|
|
520
|
+
|
|
521
|
+
if task is None:
|
|
522
|
+
if args.json:
|
|
523
|
+
print(json.dumps({"success": False, "error": "no Parallel API key"}))
|
|
524
|
+
else:
|
|
525
|
+
print(" ✗ no Parallel API key. Set PARALLEL_API_KEY or add one via the dashboard.", file=sys.stderr)
|
|
526
|
+
return 1
|
|
527
|
+
|
|
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()
|
|
347
531
|
run_id = task["run_id"]
|
|
348
532
|
created_at = task.get("created_at", "")
|
|
349
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
|
+
|
|
350
539
|
if not args.quiet:
|
|
351
540
|
print(f" · run_id: {run_id}", file=sys.stderr)
|
|
352
541
|
|
|
@@ -393,7 +582,7 @@ def cmd_run(args):
|
|
|
393
582
|
|
|
394
583
|
def cmd_status(args):
|
|
395
584
|
"""Check task run status."""
|
|
396
|
-
api_key = _get_api_key()
|
|
585
|
+
api_key = _get_key_for_run(args.run_id) or _get_api_key()
|
|
397
586
|
result = _api_request("GET", f"/v1/tasks/runs/{args.run_id}", api_key)
|
|
398
587
|
|
|
399
588
|
if args.json:
|
|
@@ -468,7 +657,7 @@ def cmd_processors(args):
|
|
|
468
657
|
|
|
469
658
|
def cmd_result(args):
|
|
470
659
|
"""Fetch completed task result."""
|
|
471
|
-
api_key = _get_api_key()
|
|
660
|
+
api_key = _get_key_for_run(args.run_id) or _get_api_key()
|
|
472
661
|
|
|
473
662
|
# Check status first
|
|
474
663
|
run = _api_request("GET", f"/v1/tasks/runs/{args.run_id}", api_key)
|
|
@@ -18,7 +18,7 @@ import sys
|
|
|
18
18
|
import time
|
|
19
19
|
import uuid
|
|
20
20
|
|
|
21
|
-
from ..services.credentials import
|
|
21
|
+
from ..services.credentials import call_with_retry, ServiceCallError
|
|
22
22
|
|
|
23
23
|
MCP_ENDPOINT = "https://search.parallel.ai/mcp"
|
|
24
24
|
REST_ENDPOINT = "https://api.parallel.ai/v1"
|
|
@@ -120,25 +120,33 @@ def cmd_search(args):
|
|
|
120
120
|
if not args.quiet:
|
|
121
121
|
print(f" ▸ searching: {objective}", file=sys.stderr)
|
|
122
122
|
|
|
123
|
-
# Try REST API with key
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
# Try REST API with key rotation, fall back to free MCP
|
|
124
|
+
data = None
|
|
126
125
|
try:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
result = call_with_retry(
|
|
127
|
+
"parallel",
|
|
128
|
+
lambda key: _call_rest_search(objective, queries, key.api_key),
|
|
129
|
+
env_var="PARALLEL_API_KEY",
|
|
130
|
+
)
|
|
131
|
+
if result is not None:
|
|
132
|
+
data = result
|
|
133
|
+
except ServiceCallError:
|
|
134
|
+
pass # All keys exhausted, fall through to MCP
|
|
135
|
+
|
|
136
|
+
if data is None:
|
|
137
|
+
try:
|
|
130
138
|
session = _session_id()
|
|
131
139
|
data = _call_mcp("web_search", {
|
|
132
140
|
"objective": objective,
|
|
133
141
|
"search_queries": queries,
|
|
134
142
|
"session_id": session,
|
|
135
143
|
})
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
except Exception as e:
|
|
145
|
+
if args.json:
|
|
146
|
+
print(json.dumps({"success": False, "error": str(e)}))
|
|
147
|
+
else:
|
|
148
|
+
print(f" ✗ search failed: {e}", file=sys.stderr)
|
|
149
|
+
return 1
|
|
142
150
|
|
|
143
151
|
elapsed = round(time.time() - t0, 1)
|
|
144
152
|
results = data.get("results", [])
|
|
@@ -188,12 +196,21 @@ def cmd_fetch(args):
|
|
|
188
196
|
if not args.quiet:
|
|
189
197
|
print(f" ▸ fetching {len(urls)} URL(s)...", file=sys.stderr)
|
|
190
198
|
|
|
191
|
-
|
|
192
|
-
|
|
199
|
+
# Try REST API with key rotation, fall back to free MCP
|
|
200
|
+
data = None
|
|
193
201
|
try:
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
202
|
+
result = call_with_retry(
|
|
203
|
+
"parallel",
|
|
204
|
+
lambda key: _call_rest_extract(urls, objective, full, key.api_key),
|
|
205
|
+
env_var="PARALLEL_API_KEY",
|
|
206
|
+
)
|
|
207
|
+
if result is not None:
|
|
208
|
+
data = result
|
|
209
|
+
except ServiceCallError:
|
|
210
|
+
pass # All keys exhausted, fall through to MCP
|
|
211
|
+
|
|
212
|
+
if data is None:
|
|
213
|
+
try:
|
|
197
214
|
session = _session_id()
|
|
198
215
|
mcp_args: dict = {"urls": urls, "session_id": session}
|
|
199
216
|
if objective:
|
|
@@ -201,12 +218,12 @@ def cmd_fetch(args):
|
|
|
201
218
|
if full:
|
|
202
219
|
mcp_args["full_content"] = True
|
|
203
220
|
data = _call_mcp("web_fetch", mcp_args)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
221
|
+
except Exception as e:
|
|
222
|
+
if args.json:
|
|
223
|
+
print(json.dumps({"success": False, "error": str(e)}))
|
|
224
|
+
else:
|
|
225
|
+
print(f" ✗ fetch failed: {e}", file=sys.stderr)
|
|
226
|
+
return 1
|
|
210
227
|
|
|
211
228
|
elapsed = round(time.time() - t0, 1)
|
|
212
229
|
results = data.get("results", [])
|
|
@@ -17,7 +17,7 @@ import json
|
|
|
17
17
|
import sys
|
|
18
18
|
import time
|
|
19
19
|
|
|
20
|
-
from ..services.credentials import
|
|
20
|
+
from ..services.credentials import call_with_retry, ServiceCallError
|
|
21
21
|
|
|
22
22
|
TAVILY_API = "https://api.tavily.com"
|
|
23
23
|
|
|
@@ -69,7 +69,6 @@ def cmd_social(args):
|
|
|
69
69
|
"""Search social media platforms."""
|
|
70
70
|
import httpx
|
|
71
71
|
|
|
72
|
-
api_key = _get_api_key()
|
|
73
72
|
query = " ".join(args.query)
|
|
74
73
|
if not query:
|
|
75
74
|
print(" ✗ search query required", file=sys.stderr)
|
|
@@ -103,18 +102,33 @@ def cmd_social(args):
|
|
|
103
102
|
if country:
|
|
104
103
|
body["country"] = country
|
|
105
104
|
|
|
106
|
-
|
|
105
|
+
def do_search(key):
|
|
107
106
|
resp = httpx.post(
|
|
108
107
|
f"{TAVILY_API}/search",
|
|
109
108
|
json=body,
|
|
110
109
|
headers={
|
|
111
|
-
"Authorization": f"Bearer {api_key}",
|
|
110
|
+
"Authorization": f"Bearer {key.api_key}",
|
|
112
111
|
"Content-Type": "application/json",
|
|
113
112
|
},
|
|
114
113
|
timeout=30,
|
|
115
114
|
)
|
|
116
115
|
resp.raise_for_status()
|
|
117
|
-
|
|
116
|
+
return resp.json()
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
data = call_with_retry("tavily", do_search, env_var="TAVILY_API_KEY")
|
|
120
|
+
if data is None:
|
|
121
|
+
if args.json:
|
|
122
|
+
print(json.dumps({"ok": False, "data": None, "error": {"code": "tavily.no_key", "message": "no Tavily API key"}, "meta": {}}))
|
|
123
|
+
else:
|
|
124
|
+
print(" ✗ no Tavily API key. Set TAVILY_API_KEY or add one via the dashboard.", file=sys.stderr)
|
|
125
|
+
return 1
|
|
126
|
+
except ServiceCallError as e:
|
|
127
|
+
if args.json:
|
|
128
|
+
print(json.dumps({"ok": False, "data": None, "error": {"code": f"tavily.{e.status_code}", "message": str(e)}, "meta": {}}))
|
|
129
|
+
else:
|
|
130
|
+
print(f" ✗ all keys exhausted: {e}", file=sys.stderr)
|
|
131
|
+
return 1
|
|
118
132
|
except httpx.HTTPStatusError as e:
|
|
119
133
|
error_msg = str(e)
|
|
120
134
|
try:
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Credential service — vends API keys from gate (internal.eightstate.co).
|
|
3
|
+
|
|
4
|
+
If the user is authenticated via `escli auth login`, keys are fetched from
|
|
5
|
+
the gate service with headroom-aware rotation. Otherwise falls back to
|
|
6
|
+
local env vars or config.
|
|
7
|
+
|
|
8
|
+
Retry support:
|
|
9
|
+
call_with_retry(service, fn) vends a key, calls fn, and on credit/rate
|
|
10
|
+
errors (402, 429) reports the failure to gate and retries with the next
|
|
11
|
+
key from the pool. Gate cooldowns the failed key so the next vend returns
|
|
12
|
+
a different one.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import pathlib
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
GATE_URL = os.environ.get("ESCLI_GATE_URL", "https://internal.eightstate.co")
|
|
22
|
+
CONFIG_DIR = pathlib.Path(os.environ.get("ESCLI_CONFIG_DIR", pathlib.Path.home() / ".escli"))
|
|
23
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
24
|
+
|
|
25
|
+
RETRYABLE_STATUSES = frozenset({402, 429})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── Config ────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
def _load_config() -> dict:
|
|
31
|
+
if CONFIG_FILE.exists():
|
|
32
|
+
try:
|
|
33
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
34
|
+
except (json.JSONDecodeError, OSError):
|
|
35
|
+
return {}
|
|
36
|
+
return {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_cli_token() -> str | None:
|
|
40
|
+
"""Get the stored CLI token from config."""
|
|
41
|
+
cfg = _load_config()
|
|
42
|
+
profile = cfg.get("active_profile", "default")
|
|
43
|
+
return cfg.get("profiles", {}).get(profile, {}).get("cli_token")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Vending & reporting ───────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def vend_key(service: str) -> dict | None:
|
|
49
|
+
"""
|
|
50
|
+
Fetch an API key for a service from gate.
|
|
51
|
+
Returns {"api_key": str, "fingerprint": str, "base_url": str | None} or None.
|
|
52
|
+
"""
|
|
53
|
+
token = get_cli_token()
|
|
54
|
+
if not token:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
import httpx
|
|
59
|
+
resp = httpx.post(
|
|
60
|
+
f"{GATE_URL}/api/keys/vend",
|
|
61
|
+
json={"service": service},
|
|
62
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
63
|
+
timeout=10,
|
|
64
|
+
)
|
|
65
|
+
if resp.status_code == 200:
|
|
66
|
+
data = resp.json()
|
|
67
|
+
if data.get("success"):
|
|
68
|
+
return {
|
|
69
|
+
"api_key": data["api_key"],
|
|
70
|
+
"fingerprint": data["fingerprint"],
|
|
71
|
+
"base_url": data.get("base_url"),
|
|
72
|
+
}
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def report_key(service: str, fingerprint: str, status: int,
|
|
80
|
+
remaining: int | None = None, limit: int | None = None,
|
|
81
|
+
reset: str | None = None, retry_after: int | None = None,
|
|
82
|
+
tier: str | None = None,
|
|
83
|
+
tokens_in: int | None = None, tokens_out: int | None = None,
|
|
84
|
+
audio_seconds: float | None = None,
|
|
85
|
+
usage_units: float | None = None, usage_sku: str | None = None):
|
|
86
|
+
"""Report rate limit state and usage metadata back to gate."""
|
|
87
|
+
token = get_cli_token()
|
|
88
|
+
if not token:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
import httpx
|
|
93
|
+
httpx.post(
|
|
94
|
+
f"{GATE_URL}/api/keys/report",
|
|
95
|
+
json={
|
|
96
|
+
"service": service,
|
|
97
|
+
"fingerprint": fingerprint,
|
|
98
|
+
"status": status,
|
|
99
|
+
"remaining": remaining,
|
|
100
|
+
"limit": limit,
|
|
101
|
+
"reset": reset,
|
|
102
|
+
"retry_after": retry_after,
|
|
103
|
+
"tier": tier,
|
|
104
|
+
"tokens_in": tokens_in,
|
|
105
|
+
"tokens_out": tokens_out,
|
|
106
|
+
"audio_seconds": audio_seconds,
|
|
107
|
+
"usage_units": usage_units,
|
|
108
|
+
"usage_sku": usage_sku,
|
|
109
|
+
},
|
|
110
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
111
|
+
timeout=5,
|
|
112
|
+
)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_key_for_service(service: str, env_var: str | None = None) -> str | None:
|
|
118
|
+
"""
|
|
119
|
+
Get an API key for a service. Priority:
|
|
120
|
+
1. Environment variable (if specified)
|
|
121
|
+
2. Gate credential vending
|
|
122
|
+
3. None
|
|
123
|
+
"""
|
|
124
|
+
if env_var:
|
|
125
|
+
key = os.environ.get(env_var)
|
|
126
|
+
if key:
|
|
127
|
+
return key
|
|
128
|
+
|
|
129
|
+
result = vend_key(service)
|
|
130
|
+
if result:
|
|
131
|
+
return result["api_key"]
|
|
132
|
+
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── Retry wrapper ─────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
class VendedKey:
|
|
139
|
+
"""Credential vended from gate (or from env var)."""
|
|
140
|
+
__slots__ = ("api_key", "fingerprint", "base_url")
|
|
141
|
+
|
|
142
|
+
def __init__(self, api_key: str, fingerprint: str, base_url: str | None = None):
|
|
143
|
+
self.api_key = api_key
|
|
144
|
+
self.fingerprint = fingerprint
|
|
145
|
+
self.base_url = base_url
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ServiceCallError(Exception):
|
|
149
|
+
"""All vended keys exhausted after retries."""
|
|
150
|
+
def __init__(self, status_code: int, message: str = ""):
|
|
151
|
+
self.status_code = status_code
|
|
152
|
+
self.message = message
|
|
153
|
+
super().__init__(f"keys exhausted (last status {status_code}): {message}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _extract_status(exc: Exception) -> int | None:
|
|
157
|
+
"""Extract HTTP status code from various exception types."""
|
|
158
|
+
# httpx.HTTPStatusError
|
|
159
|
+
resp = getattr(exc, "response", None)
|
|
160
|
+
if resp is not None and hasattr(resp, "status_code"):
|
|
161
|
+
return resp.status_code
|
|
162
|
+
# urllib.error.HTTPError
|
|
163
|
+
if hasattr(exc, "code") and isinstance(getattr(exc, "code"), int):
|
|
164
|
+
return exc.code
|
|
165
|
+
# openai.APIStatusError
|
|
166
|
+
if hasattr(exc, "status_code") and isinstance(exc.status_code, int):
|
|
167
|
+
return exc.status_code
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def call_with_retry(service: str, fn, max_attempts: int = 3,
|
|
172
|
+
env_var: str | None = None):
|
|
173
|
+
"""
|
|
174
|
+
Vend key → call fn(VendedKey) → retry on credit/rate errors.
|
|
175
|
+
|
|
176
|
+
On 402/429 from fn, reports the failure to gate (which cooldowns /
|
|
177
|
+
disables the key) then vends the next key and retries.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
fn result on success.
|
|
181
|
+
None if no keys available at all (no pool, no env var).
|
|
182
|
+
Raises:
|
|
183
|
+
ServiceCallError if all keys exhausted after max_attempts.
|
|
184
|
+
Any non-retryable exception from fn propagates as-is.
|
|
185
|
+
"""
|
|
186
|
+
# Env var bypass — user-provided key, no retry
|
|
187
|
+
if env_var:
|
|
188
|
+
key = os.environ.get(env_var)
|
|
189
|
+
if key:
|
|
190
|
+
return fn(VendedKey(api_key=key, fingerprint="env"))
|
|
191
|
+
|
|
192
|
+
last_error = None
|
|
193
|
+
for _ in range(max_attempts):
|
|
194
|
+
result = vend_key(service)
|
|
195
|
+
if not result:
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
vended = VendedKey(
|
|
199
|
+
api_key=result["api_key"],
|
|
200
|
+
fingerprint=result["fingerprint"],
|
|
201
|
+
base_url=result.get("base_url"),
|
|
202
|
+
)
|
|
203
|
+
try:
|
|
204
|
+
return fn(vended)
|
|
205
|
+
except Exception as e:
|
|
206
|
+
status = _extract_status(e)
|
|
207
|
+
if status is not None and status in RETRYABLE_STATUSES:
|
|
208
|
+
report_key(service, vended.fingerprint, status)
|
|
209
|
+
last_error = ServiceCallError(status, str(e)[:200])
|
|
210
|
+
continue
|
|
211
|
+
raise
|
|
212
|
+
|
|
213
|
+
if last_error:
|
|
214
|
+
raise last_error
|
|
215
|
+
return None
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Credential service — vends API keys from gate (internal.eightstate.co).
|
|
3
|
-
|
|
4
|
-
If the user is authenticated via `escli auth login`, keys are fetched from
|
|
5
|
-
the gate service with headroom-aware rotation. Otherwise falls back to
|
|
6
|
-
local env vars or config.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import json
|
|
10
|
-
import os
|
|
11
|
-
import pathlib
|
|
12
|
-
import sys
|
|
13
|
-
import time
|
|
14
|
-
|
|
15
|
-
GATE_URL = os.environ.get("ESCLI_GATE_URL", "https://internal.eightstate.co")
|
|
16
|
-
CONFIG_DIR = pathlib.Path(os.environ.get("ESCLI_CONFIG_DIR", pathlib.Path.home() / ".escli"))
|
|
17
|
-
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _load_config() -> dict:
|
|
21
|
-
if CONFIG_FILE.exists():
|
|
22
|
-
try:
|
|
23
|
-
return json.loads(CONFIG_FILE.read_text())
|
|
24
|
-
except (json.JSONDecodeError, OSError):
|
|
25
|
-
return {}
|
|
26
|
-
return {}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def get_cli_token() -> str | None:
|
|
30
|
-
"""Get the stored CLI token from config."""
|
|
31
|
-
cfg = _load_config()
|
|
32
|
-
profile = cfg.get("active_profile", "default")
|
|
33
|
-
return cfg.get("profiles", {}).get(profile, {}).get("cli_token")
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def vend_key(service: str) -> dict | None:
|
|
37
|
-
"""
|
|
38
|
-
Fetch an API key for a service from gate.
|
|
39
|
-
Returns {"api_key": str, "fingerprint": str} or None.
|
|
40
|
-
"""
|
|
41
|
-
token = get_cli_token()
|
|
42
|
-
if not token:
|
|
43
|
-
return None
|
|
44
|
-
|
|
45
|
-
try:
|
|
46
|
-
import httpx
|
|
47
|
-
resp = httpx.post(
|
|
48
|
-
f"{GATE_URL}/api/keys/vend",
|
|
49
|
-
json={"service": service},
|
|
50
|
-
headers={"Authorization": f"Bearer {token}"},
|
|
51
|
-
timeout=10,
|
|
52
|
-
)
|
|
53
|
-
if resp.status_code == 200:
|
|
54
|
-
data = resp.json()
|
|
55
|
-
if data.get("success"):
|
|
56
|
-
return {"api_key": data["api_key"], "fingerprint": data["fingerprint"]}
|
|
57
|
-
except Exception:
|
|
58
|
-
pass
|
|
59
|
-
|
|
60
|
-
return None
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def report_key(service: str, fingerprint: str, status: int,
|
|
64
|
-
remaining: int | None = None, limit: int | None = None,
|
|
65
|
-
reset: str | None = None, retry_after: int | None = None,
|
|
66
|
-
tier: str | None = None,
|
|
67
|
-
tokens_in: int | None = None, tokens_out: int | None = None,
|
|
68
|
-
audio_seconds: float | None = None,
|
|
69
|
-
usage_units: float | None = None, usage_sku: str | None = None):
|
|
70
|
-
"""Report rate limit state and usage metadata back to gate."""
|
|
71
|
-
token = get_cli_token()
|
|
72
|
-
if not token:
|
|
73
|
-
return
|
|
74
|
-
|
|
75
|
-
try:
|
|
76
|
-
import httpx
|
|
77
|
-
httpx.post(
|
|
78
|
-
f"{GATE_URL}/api/keys/report",
|
|
79
|
-
json={
|
|
80
|
-
"service": service,
|
|
81
|
-
"fingerprint": fingerprint,
|
|
82
|
-
"status": status,
|
|
83
|
-
"remaining": remaining,
|
|
84
|
-
"limit": limit,
|
|
85
|
-
"reset": reset,
|
|
86
|
-
"retry_after": retry_after,
|
|
87
|
-
"tier": tier,
|
|
88
|
-
"tokens_in": tokens_in,
|
|
89
|
-
"tokens_out": tokens_out,
|
|
90
|
-
"audio_seconds": audio_seconds,
|
|
91
|
-
"usage_units": usage_units,
|
|
92
|
-
"usage_sku": usage_sku,
|
|
93
|
-
},
|
|
94
|
-
headers={"Authorization": f"Bearer {token}"},
|
|
95
|
-
timeout=5,
|
|
96
|
-
)
|
|
97
|
-
except Exception:
|
|
98
|
-
pass
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def get_key_for_service(service: str, env_var: str | None = None) -> str | None:
|
|
102
|
-
"""
|
|
103
|
-
Get an API key for a service. Priority:
|
|
104
|
-
1. Environment variable (if specified)
|
|
105
|
-
2. Gate credential vending
|
|
106
|
-
3. None
|
|
107
|
-
"""
|
|
108
|
-
if env_var:
|
|
109
|
-
key = os.environ.get(env_var)
|
|
110
|
-
if key:
|
|
111
|
-
return key
|
|
112
|
-
|
|
113
|
-
result = vend_key(service)
|
|
114
|
-
if result:
|
|
115
|
-
return result["api_key"]
|
|
116
|
-
|
|
117
|
-
return None
|
|
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
|