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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eightstatecli
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Eightstate CLI — unified AI services from the command line
5
5
  Project-URL: Homepage, https://github.com/eight-state/eightstate
6
6
  Project-URL: Repository, https://github.com/eight-state/eightstate
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "eightstatecli"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
  description = "Eightstate CLI — unified AI services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -17,7 +17,7 @@ AGENT INSTRUCTIONS:
17
17
  - Exit codes: 0=success, 1=error, 2=usage
18
18
  """
19
19
 
20
- __version__ = "0.4.0"
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
- return [{
146
- "name": n, "label": d.get("label", n),
147
- "key": f"{d.get('api_key','')[:8]}...{d.get('api_key','')[-4:]}" if len(d.get("api_key","")) > 12 else "***",
148
- "endpoint": d.get("endpoint", ENDPOINT),
149
- "active": n == active, "created": d.get("created", ""),
150
- } for n, d in cfg.get("profiles", {}).items()]
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": has_key, "profile": profile["name"], "endpoint": profile["endpoint"],
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 has_key:
380
- print(box(f"{CHECK} authenticated", [f"profile {profile['name']}",
381
- f"key {mask_key(profile['api_key'])}",
382
- f"endpoint {profile['endpoint']}"]))
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
- api_key, base_url = ensure_authed(args)
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
- t0 = time.time()
442
- try:
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
- resp = client.images.generate(**kwargs)
446
- except Exception as e:
447
- if args.json: print(jsonlib.dumps({"error": str(e), "success": False}))
448
- else: log(f" {CROSS} {e}", args.quiet)
449
- sys.exit(1)
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
- api_key, base_url = ensure_authed(args)
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
- t0 = time.time()
510
- try:
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(); data = r.json()
515
- except Exception as e:
516
- if args.json: print(jsonlib.dumps({"error": str(e), "success": False}))
517
- else: log(f" {CROSS} {e}", args.quiet)
518
- sys.exit(1)
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
- api_key, base_url = ensure_authed(args)
614
+ from .services.credentials import call_with_retry, ServiceCallError, VendedKey
615
+
540
616
  OpenAI = require_openai()
541
- try:
542
- models = OpenAI(base_url=base_url, api_key=api_key, timeout=30).models.list()
543
- except Exception as e:
544
- if args.json: print(jsonlib.dumps({"error": str(e), "success": False}))
545
- else: print(f" {CROSS} {e}", file=sys.stderr)
546
- sys.exit(1)
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
- audio_url = _upload_file(source, api_key, args.quiet)
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
- if resp.status_code == 429:
98
- print(" ✗ rate limited. try again later.", file=sys.stderr)
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
- data = _request(url, api_key)
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
- data = _request(url, api_key)
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
- resp = _request_raw(url, api_key)
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
- if resp.status_code == 429:
283
- print(" ✗ rate limited. try again later.", file=sys.stderr)
284
- sys.exit(1)
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 get_key_for_service
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) -> dict:
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
- return None
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
- task = _api_request("POST", "/v1/tasks/runs", api_key, body=body, extra_headers=headers)
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 get_key_for_service
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 first (better quality), fall back to MCP (free)
124
- api_key = get_key_for_service("parallel", "PARALLEL_API_KEY")
125
-
123
+ # Try REST API with key rotation, fall back to free MCP
124
+ data = None
126
125
  try:
127
- if api_key:
128
- data = _call_rest_search(objective, queries, api_key)
129
- else:
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
- except Exception as e:
137
- if args.json:
138
- print(json.dumps({"success": False, "error": str(e)}))
139
- else:
140
- print(f" ✗ search failed: {e}", file=sys.stderr)
141
- return 1
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
- api_key = get_key_for_service("parallel", "PARALLEL_API_KEY")
192
-
199
+ # Try REST API with key rotation, fall back to free MCP
200
+ data = None
193
201
  try:
194
- if api_key:
195
- data = _call_rest_extract(urls, objective, full, api_key)
196
- else:
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
- except Exception as e:
205
- if args.json:
206
- print(json.dumps({"success": False, "error": str(e)}))
207
- else:
208
- print(f" ✗ fetch failed: {e}", file=sys.stderr)
209
- return 1
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 get_key_for_service
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
- try:
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
- data = resp.json()
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