eightstatecli 0.4.0__tar.gz → 0.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/PKG-INFO +1 -1
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/pyproject.toml +1 -1
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/src/escli/__init__.py +143 -38
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/src/escli/commands/audio.py +24 -4
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/src/escli/commands/docs.py +40 -14
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/src/escli/commands/research.py +48 -5
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/src/escli/commands/search.py +41 -24
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/src/escli/commands/social.py +19 -5
- eightstatecli-0.4.1/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.1}/.gitignore +0 -0
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/LICENSE +0 -0
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/README.md +0 -0
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/src/escli/__main__.py +0 -0
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/src/escli/commands/__init__.py +0 -0
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/src/escli/commands/usage.py +0 -0
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/src/escli/services/__init__.py +0 -0
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/src/escli/services/describe.py +0 -0
- {eightstatecli-0.4.0 → eightstatecli-0.4.1}/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.1
|
|
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,7 +27,7 @@ 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
|
|
30
|
+
from ..services.credentials import get_key_for_service, call_with_retry, ServiceCallError
|
|
31
31
|
|
|
32
32
|
API_BASE = "https://api.parallel.ai"
|
|
33
33
|
SSE_BETA = "events-sse-2025-07-24"
|
|
@@ -51,7 +51,8 @@ def _get_api_key() -> str:
|
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def _api_request(method: str, path: str, api_key: str,
|
|
54
|
-
body: dict | None = None, extra_headers: dict | None = None
|
|
54
|
+
body: dict | None = None, extra_headers: dict | None = None,
|
|
55
|
+
raise_on_error: bool = False) -> dict:
|
|
55
56
|
url = f"{API_BASE}{path}"
|
|
56
57
|
hdrs = {"x-api-key": api_key, "Content-Type": "application/json"}
|
|
57
58
|
if extra_headers:
|
|
@@ -70,6 +71,8 @@ def _api_request(method: str, path: str, api_key: str,
|
|
|
70
71
|
err_body = {"error": {"message": str(e)}}
|
|
71
72
|
|
|
72
73
|
msg = err_body.get("error", {}).get("message", str(e))
|
|
74
|
+
if raise_on_error:
|
|
75
|
+
raise
|
|
73
76
|
if e.code == 401:
|
|
74
77
|
print(f" ✗ auth failed (401): {msg}", file=sys.stderr); sys.exit(1)
|
|
75
78
|
elif e.code == 429:
|
|
@@ -77,6 +80,8 @@ def _api_request(method: str, path: str, api_key: str,
|
|
|
77
80
|
else:
|
|
78
81
|
print(f" ✗ API error ({e.code}): {msg}", file=sys.stderr); sys.exit(2)
|
|
79
82
|
except urllib.error.URLError as e:
|
|
83
|
+
if raise_on_error:
|
|
84
|
+
raise
|
|
80
85
|
print(f" ✗ network error: {e.reason}", file=sys.stderr); sys.exit(2)
|
|
81
86
|
|
|
82
87
|
|
|
@@ -269,7 +274,6 @@ def _render_table(lines: list[str], items: list[dict]):
|
|
|
269
274
|
|
|
270
275
|
def cmd_run(args):
|
|
271
276
|
"""Create and execute a research task."""
|
|
272
|
-
api_key = _get_api_key()
|
|
273
277
|
query = " ".join(args.query)
|
|
274
278
|
processor = args.processor
|
|
275
279
|
|
|
@@ -341,9 +345,48 @@ def cmd_run(args):
|
|
|
341
345
|
if getattr(args, "follow_up", None):
|
|
342
346
|
body["previous_interaction_id"] = args.follow_up
|
|
343
347
|
|
|
344
|
-
# Submit
|
|
348
|
+
# Submit with retry — vend key, submit task, retry on 402/429
|
|
345
349
|
headers = {"parallel-beta": SSE_BETA}
|
|
346
|
-
|
|
350
|
+
|
|
351
|
+
def submit(key):
|
|
352
|
+
return _api_request("POST", "/v1/tasks/runs", key.api_key,
|
|
353
|
+
body=body, extra_headers=headers, raise_on_error=True)
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
task = call_with_retry("parallel", submit, env_var="PARALLEL_API_KEY")
|
|
357
|
+
except ServiceCallError as e:
|
|
358
|
+
if args.json:
|
|
359
|
+
print(json.dumps({"success": False, "error": str(e)}))
|
|
360
|
+
else:
|
|
361
|
+
print(f" ✗ all keys exhausted: {e}", file=sys.stderr)
|
|
362
|
+
return 1
|
|
363
|
+
except urllib.error.HTTPError as e:
|
|
364
|
+
try:
|
|
365
|
+
err_body = json.loads(e.read().decode())
|
|
366
|
+
except Exception:
|
|
367
|
+
err_body = {"error": {"message": str(e)}}
|
|
368
|
+
msg = err_body.get("error", {}).get("message", str(e))
|
|
369
|
+
if args.json:
|
|
370
|
+
print(json.dumps({"success": False, "error": msg}))
|
|
371
|
+
else:
|
|
372
|
+
print(f" ✗ API error ({e.code}): {msg}", file=sys.stderr)
|
|
373
|
+
return 1
|
|
374
|
+
except Exception as e:
|
|
375
|
+
if args.json:
|
|
376
|
+
print(json.dumps({"success": False, "error": str(e)}))
|
|
377
|
+
else:
|
|
378
|
+
print(f" ✗ {e}", file=sys.stderr)
|
|
379
|
+
return 1
|
|
380
|
+
|
|
381
|
+
if task is None:
|
|
382
|
+
if args.json:
|
|
383
|
+
print(json.dumps({"success": False, "error": "no Parallel API key"}))
|
|
384
|
+
else:
|
|
385
|
+
print(" ✗ no Parallel API key. Set PARALLEL_API_KEY or add one via the dashboard.", file=sys.stderr)
|
|
386
|
+
return 1
|
|
387
|
+
|
|
388
|
+
# From here on, use the same key for polling (already authenticated)
|
|
389
|
+
api_key = _get_api_key()
|
|
347
390
|
run_id = task["run_id"]
|
|
348
391
|
created_at = task.get("created_at", "")
|
|
349
392
|
|
|
@@ -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
|