eightstatecli 0.4.0__py3-none-any.whl
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.dist-info/METADATA +177 -0
- eightstatecli-0.4.0.dist-info/RECORD +18 -0
- eightstatecli-0.4.0.dist-info/WHEEL +4 -0
- eightstatecli-0.4.0.dist-info/entry_points.txt +2 -0
- eightstatecli-0.4.0.dist-info/licenses/LICENSE +21 -0
- escli/__init__.py +837 -0
- escli/__main__.py +5 -0
- escli/commands/__init__.py +0 -0
- escli/commands/audio.py +438 -0
- escli/commands/docs.py +354 -0
- escli/commands/research.py +597 -0
- escli/commands/search.py +286 -0
- escli/commands/social.py +243 -0
- escli/commands/usage.py +428 -0
- escli/services/__init__.py +0 -0
- escli/services/credentials.py +117 -0
- escli/services/describe.py +186 -0
- escli/services/output.py +168 -0
escli/commands/usage.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""
|
|
2
|
+
escli usage — view API usage and spend analytics from gate.
|
|
3
|
+
|
|
4
|
+
Pulls aggregated usage data from the eightstate gate service. Authenticated
|
|
5
|
+
via the CLI token from `escli auth login`.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
escli usage Last 30 days summary
|
|
9
|
+
escli usage --days 7 Last 7 days summary
|
|
10
|
+
escli usage --service parallel Filter to one service
|
|
11
|
+
escli usage --pricing Show current pricing rules
|
|
12
|
+
escli usage --daily Day-by-day breakdown
|
|
13
|
+
escli --json usage Structured JSON output
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from datetime import datetime, timedelta, timezone
|
|
21
|
+
|
|
22
|
+
from ..services.credentials import get_cli_token
|
|
23
|
+
|
|
24
|
+
GATE_URL = os.environ.get("ESCLI_GATE_URL", "https://internal.eightstate.co")
|
|
25
|
+
|
|
26
|
+
# Box drawing — duplicated from __init__ to keep this module self-contained.
|
|
27
|
+
BOX_H = "─"
|
|
28
|
+
BULLET = "▸"
|
|
29
|
+
DOT = "·"
|
|
30
|
+
CHECK = "✓"
|
|
31
|
+
CROSS = "✗"
|
|
32
|
+
ARROW = "→"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── HTTP ─────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
def _require_token() -> str:
|
|
38
|
+
token = get_cli_token()
|
|
39
|
+
if not token:
|
|
40
|
+
print(f" {CROSS} not authenticated", file=sys.stderr)
|
|
41
|
+
print(f" {ARROW} run: escli auth login", file=sys.stderr)
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
return token
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get(path: str, params: dict | None, token: str, timeout: int = 30) -> dict:
|
|
47
|
+
import httpx
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
resp = httpx.get(
|
|
51
|
+
f"{GATE_URL}{path}",
|
|
52
|
+
params=params or None,
|
|
53
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
54
|
+
timeout=timeout,
|
|
55
|
+
)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
raise RuntimeError(f"network error: {e}") from e
|
|
58
|
+
|
|
59
|
+
if resp.status_code == 401:
|
|
60
|
+
raise RuntimeError("auth failed (401) — re-run: escli auth login")
|
|
61
|
+
if resp.status_code == 403:
|
|
62
|
+
raise RuntimeError("forbidden (403)")
|
|
63
|
+
if resp.status_code >= 400:
|
|
64
|
+
try:
|
|
65
|
+
body = resp.json()
|
|
66
|
+
msg = body.get("error") or body.get("message") or resp.text
|
|
67
|
+
except Exception:
|
|
68
|
+
msg = resp.text
|
|
69
|
+
raise RuntimeError(f"API error ({resp.status_code}): {msg}")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
return resp.json()
|
|
73
|
+
except Exception as e:
|
|
74
|
+
raise RuntimeError(f"invalid JSON response: {e}") from e
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── Date helpers ─────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
def _date_range(days: int) -> tuple[str, str]:
|
|
80
|
+
end = datetime.now(timezone.utc)
|
|
81
|
+
start = end - timedelta(days=days)
|
|
82
|
+
return start.isoformat(), end.isoformat()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── Formatting ───────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def _fmt_money(usd: float | int | None) -> str:
|
|
88
|
+
if usd is None:
|
|
89
|
+
return "—"
|
|
90
|
+
try:
|
|
91
|
+
v = float(usd)
|
|
92
|
+
except (TypeError, ValueError):
|
|
93
|
+
return "—"
|
|
94
|
+
if v == 0:
|
|
95
|
+
return "$0.00"
|
|
96
|
+
if abs(v) < 0.01:
|
|
97
|
+
return f"${v:.4f}"
|
|
98
|
+
return f"${v:,.2f}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _fmt_int(n: int | float | None) -> str:
|
|
102
|
+
if n is None:
|
|
103
|
+
return "—"
|
|
104
|
+
try:
|
|
105
|
+
return f"{int(n):,}"
|
|
106
|
+
except (TypeError, ValueError):
|
|
107
|
+
return str(n)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _print_summary(data: dict, days: int, service: str | None):
|
|
111
|
+
"""Render the summary box."""
|
|
112
|
+
total_requests = (
|
|
113
|
+
data.get("total_requests")
|
|
114
|
+
or data.get("requests")
|
|
115
|
+
or data.get("total_events")
|
|
116
|
+
or 0
|
|
117
|
+
)
|
|
118
|
+
total_cost = (
|
|
119
|
+
data.get("total_cost_usd")
|
|
120
|
+
or data.get("total_cost")
|
|
121
|
+
or data.get("estimated_cost_usd")
|
|
122
|
+
or 0.0
|
|
123
|
+
)
|
|
124
|
+
by_service = (
|
|
125
|
+
data.get("by_service")
|
|
126
|
+
or data.get("services")
|
|
127
|
+
or []
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
range_start = data.get("start") or data.get("range_start")
|
|
131
|
+
range_end = data.get("end") or data.get("range_end")
|
|
132
|
+
|
|
133
|
+
title = f" Usage {DOT} last {days} day{'s' if days != 1 else ''}"
|
|
134
|
+
if service:
|
|
135
|
+
title += f" {DOT} service: {service}"
|
|
136
|
+
|
|
137
|
+
print()
|
|
138
|
+
print(title)
|
|
139
|
+
if range_start and range_end:
|
|
140
|
+
# Trim ISO microseconds for readability.
|
|
141
|
+
s = range_start.split(".")[0].replace("T", " ")
|
|
142
|
+
e = range_end.split(".")[0].replace("T", " ")
|
|
143
|
+
print(f" {DOT} {s} {ARROW} {e}")
|
|
144
|
+
print(f" {BOX_H * 60}")
|
|
145
|
+
print(f" {'requests':<24} {_fmt_int(total_requests):>20}")
|
|
146
|
+
print(f" {'estimated spend':<24} {_fmt_money(total_cost):>20}")
|
|
147
|
+
|
|
148
|
+
if by_service:
|
|
149
|
+
print()
|
|
150
|
+
print(f" {'SERVICE':<16} {'REQUESTS':>12} {'SPEND':>14}")
|
|
151
|
+
print(f" {BOX_H * 46}")
|
|
152
|
+
# by_service may be list[dict] or dict[name -> stats]
|
|
153
|
+
rows: list[tuple[str, int, float]] = []
|
|
154
|
+
if isinstance(by_service, dict):
|
|
155
|
+
for name, stats in by_service.items():
|
|
156
|
+
rows.append((
|
|
157
|
+
name,
|
|
158
|
+
int(stats.get("requests") or stats.get("count") or 0),
|
|
159
|
+
float(stats.get("cost_usd") or stats.get("cost") or 0.0),
|
|
160
|
+
))
|
|
161
|
+
else:
|
|
162
|
+
for entry in by_service:
|
|
163
|
+
name = entry.get("service") or entry.get("name") or "?"
|
|
164
|
+
rows.append((
|
|
165
|
+
name,
|
|
166
|
+
int(entry.get("requests") or entry.get("count") or 0),
|
|
167
|
+
float(entry.get("cost_usd") or entry.get("cost") or 0.0),
|
|
168
|
+
))
|
|
169
|
+
rows.sort(key=lambda r: r[2], reverse=True)
|
|
170
|
+
for name, requests, cost in rows:
|
|
171
|
+
print(f" {name:<16} {_fmt_int(requests):>12} {_fmt_money(cost):>14}")
|
|
172
|
+
|
|
173
|
+
print()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _print_by_service(data: dict, days: int):
|
|
177
|
+
"""Render the by-service breakdown."""
|
|
178
|
+
services = data.get("services") or data.get("by_service") or []
|
|
179
|
+
|
|
180
|
+
print()
|
|
181
|
+
print(f" By service {DOT} last {days} day{'s' if days != 1 else ''}")
|
|
182
|
+
print(f" {BOX_H * 70}")
|
|
183
|
+
print(f" {'SERVICE':<14} {'REQUESTS':>10} {'TOKENS IN':>12} {'TOKENS OUT':>12} {'SPEND':>14}")
|
|
184
|
+
print(f" {BOX_H * 70}")
|
|
185
|
+
|
|
186
|
+
rows = services if isinstance(services, list) else [
|
|
187
|
+
{"service": k, **(v if isinstance(v, dict) else {})} for k, v in services.items()
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
for entry in rows:
|
|
191
|
+
name = entry.get("service") or entry.get("name") or "?"
|
|
192
|
+
requests = entry.get("requests") or entry.get("count") or 0
|
|
193
|
+
tokens_in = entry.get("tokens_in") or 0
|
|
194
|
+
tokens_out = entry.get("tokens_out") or 0
|
|
195
|
+
cost = entry.get("cost_usd") or entry.get("cost") or 0.0
|
|
196
|
+
print(
|
|
197
|
+
f" {name:<14} {_fmt_int(requests):>10} {_fmt_int(tokens_in):>12} "
|
|
198
|
+
f"{_fmt_int(tokens_out):>12} {_fmt_money(cost):>14}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
print()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _print_by_day(data: dict, days: int, service: str | None):
|
|
205
|
+
"""Render the day-by-day breakdown."""
|
|
206
|
+
rows = data.get("days") or data.get("by_day") or []
|
|
207
|
+
|
|
208
|
+
title = f" Daily usage {DOT} last {days} day{'s' if days != 1 else ''}"
|
|
209
|
+
if service:
|
|
210
|
+
title += f" {DOT} service: {service}"
|
|
211
|
+
|
|
212
|
+
print()
|
|
213
|
+
print(title)
|
|
214
|
+
print(f" {BOX_H * 50}")
|
|
215
|
+
print(f" {'DATE':<12} {'REQUESTS':>12} {'SPEND':>14}")
|
|
216
|
+
print(f" {BOX_H * 50}")
|
|
217
|
+
|
|
218
|
+
total_requests = 0
|
|
219
|
+
total_cost = 0.0
|
|
220
|
+
for entry in rows:
|
|
221
|
+
date = entry.get("date") or entry.get("day") or "?"
|
|
222
|
+
# Normalize date display
|
|
223
|
+
if isinstance(date, str) and "T" in date:
|
|
224
|
+
date = date.split("T")[0]
|
|
225
|
+
requests = int(entry.get("requests") or entry.get("count") or 0)
|
|
226
|
+
cost = float(entry.get("cost_usd") or entry.get("cost") or 0.0)
|
|
227
|
+
total_requests += requests
|
|
228
|
+
total_cost += cost
|
|
229
|
+
print(f" {date:<12} {_fmt_int(requests):>12} {_fmt_money(cost):>14}")
|
|
230
|
+
|
|
231
|
+
if rows:
|
|
232
|
+
print(f" {BOX_H * 50}")
|
|
233
|
+
print(f" {'total':<12} {_fmt_int(total_requests):>12} {_fmt_money(total_cost):>14}")
|
|
234
|
+
else:
|
|
235
|
+
print(" (no events in range)")
|
|
236
|
+
|
|
237
|
+
print()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _print_pricing(data: dict):
|
|
241
|
+
"""Render pricing rules."""
|
|
242
|
+
rules = data.get("rules") or data.get("pricing") or data.get("pricing_rules") or []
|
|
243
|
+
|
|
244
|
+
print()
|
|
245
|
+
print(f" Pricing rules")
|
|
246
|
+
print(f" {BOX_H * 70}")
|
|
247
|
+
print(f" {'SERVICE':<14} {'SKU':<24} {'UNIT':<14} {'COST/UNIT':>14}")
|
|
248
|
+
print(f" {BOX_H * 70}")
|
|
249
|
+
|
|
250
|
+
rows = rules if isinstance(rules, list) else []
|
|
251
|
+
rows.sort(key=lambda r: (r.get("service", ""), r.get("sku", "")))
|
|
252
|
+
|
|
253
|
+
for rule in rows:
|
|
254
|
+
if rule.get("enabled") == 0:
|
|
255
|
+
continue
|
|
256
|
+
service = rule.get("service", "?")
|
|
257
|
+
sku = rule.get("sku", "?")
|
|
258
|
+
unit = rule.get("unit_label") or rule.get("unit") or ""
|
|
259
|
+
cost = rule.get("cost_per_unit")
|
|
260
|
+
if cost is None:
|
|
261
|
+
cost_str = "—"
|
|
262
|
+
else:
|
|
263
|
+
try:
|
|
264
|
+
f = float(cost)
|
|
265
|
+
# Pricing often very small; show more decimals
|
|
266
|
+
cost_str = f"${f:.6f}" if f < 0.01 else f"${f:.4f}"
|
|
267
|
+
except (TypeError, ValueError):
|
|
268
|
+
cost_str = str(cost)
|
|
269
|
+
print(f" {service:<14} {sku:<24} {unit:<14} {cost_str:>14}")
|
|
270
|
+
|
|
271
|
+
if not rows:
|
|
272
|
+
print(" (no pricing rules configured)")
|
|
273
|
+
|
|
274
|
+
print()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ── Commands ─────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
def cmd_usage(args):
|
|
280
|
+
"""Show usage analytics."""
|
|
281
|
+
token = _require_token()
|
|
282
|
+
days = max(1, int(getattr(args, "days", 30) or 30))
|
|
283
|
+
service = getattr(args, "service", None)
|
|
284
|
+
|
|
285
|
+
# --pricing: fetch and show pricing rules, ignore date range.
|
|
286
|
+
if getattr(args, "pricing", False):
|
|
287
|
+
if not args.quiet and not args.json:
|
|
288
|
+
print(f" {BULLET} fetching pricing rules...", file=sys.stderr)
|
|
289
|
+
try:
|
|
290
|
+
data = _get("/api/analytics/pricing", None, token)
|
|
291
|
+
except RuntimeError as e:
|
|
292
|
+
if args.json:
|
|
293
|
+
print(json.dumps({"success": False, "error": str(e)}))
|
|
294
|
+
else:
|
|
295
|
+
print(f" {CROSS} {e}", file=sys.stderr)
|
|
296
|
+
return 1
|
|
297
|
+
|
|
298
|
+
if args.json:
|
|
299
|
+
print(json.dumps({"success": True, **data}))
|
|
300
|
+
else:
|
|
301
|
+
_print_pricing(data)
|
|
302
|
+
return 0
|
|
303
|
+
|
|
304
|
+
start, end = _date_range(days)
|
|
305
|
+
params = {"start": start, "end": end}
|
|
306
|
+
if service:
|
|
307
|
+
params["service"] = service
|
|
308
|
+
|
|
309
|
+
# --daily: day-by-day breakdown
|
|
310
|
+
if getattr(args, "daily", False):
|
|
311
|
+
if not args.quiet and not args.json:
|
|
312
|
+
print(f" {BULLET} fetching daily breakdown ({days} days)...", file=sys.stderr)
|
|
313
|
+
try:
|
|
314
|
+
data = _get("/api/analytics/by-day", params, token)
|
|
315
|
+
except RuntimeError as e:
|
|
316
|
+
if args.json:
|
|
317
|
+
print(json.dumps({"success": False, "error": str(e)}))
|
|
318
|
+
else:
|
|
319
|
+
print(f" {CROSS} {e}", file=sys.stderr)
|
|
320
|
+
return 1
|
|
321
|
+
|
|
322
|
+
if args.json:
|
|
323
|
+
print(json.dumps({
|
|
324
|
+
"success": True,
|
|
325
|
+
"days": days,
|
|
326
|
+
"service": service,
|
|
327
|
+
"start": start,
|
|
328
|
+
"end": end,
|
|
329
|
+
**data,
|
|
330
|
+
}))
|
|
331
|
+
else:
|
|
332
|
+
_print_by_day(data, days, service)
|
|
333
|
+
return 0
|
|
334
|
+
|
|
335
|
+
# --service (filter without --daily): show by-service detail
|
|
336
|
+
if service:
|
|
337
|
+
if not args.quiet and not args.json:
|
|
338
|
+
print(f" {BULLET} fetching usage for {service} ({days} days)...", file=sys.stderr)
|
|
339
|
+
try:
|
|
340
|
+
data = _get("/api/analytics/by-service", params, token)
|
|
341
|
+
except RuntimeError as e:
|
|
342
|
+
if args.json:
|
|
343
|
+
print(json.dumps({"success": False, "error": str(e)}))
|
|
344
|
+
else:
|
|
345
|
+
print(f" {CROSS} {e}", file=sys.stderr)
|
|
346
|
+
return 1
|
|
347
|
+
|
|
348
|
+
if args.json:
|
|
349
|
+
print(json.dumps({
|
|
350
|
+
"success": True,
|
|
351
|
+
"days": days,
|
|
352
|
+
"service": service,
|
|
353
|
+
"start": start,
|
|
354
|
+
"end": end,
|
|
355
|
+
**data,
|
|
356
|
+
}))
|
|
357
|
+
else:
|
|
358
|
+
_print_by_service(data, days)
|
|
359
|
+
return 0
|
|
360
|
+
|
|
361
|
+
# Default: summary across all services
|
|
362
|
+
if not args.quiet and not args.json:
|
|
363
|
+
print(f" {BULLET} fetching usage summary ({days} days)...", file=sys.stderr)
|
|
364
|
+
try:
|
|
365
|
+
data = _get("/api/analytics/summary", params, token)
|
|
366
|
+
except RuntimeError as e:
|
|
367
|
+
if args.json:
|
|
368
|
+
print(json.dumps({"success": False, "error": str(e)}))
|
|
369
|
+
else:
|
|
370
|
+
print(f" {CROSS} {e}", file=sys.stderr)
|
|
371
|
+
return 1
|
|
372
|
+
|
|
373
|
+
if args.json:
|
|
374
|
+
print(json.dumps({
|
|
375
|
+
"success": True,
|
|
376
|
+
"days": days,
|
|
377
|
+
"start": start,
|
|
378
|
+
"end": end,
|
|
379
|
+
**data,
|
|
380
|
+
}))
|
|
381
|
+
else:
|
|
382
|
+
_print_summary(data, days, service)
|
|
383
|
+
return 0
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ── Parser ───────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
def register(subparsers):
|
|
389
|
+
"""Register the usage subcommand."""
|
|
390
|
+
F = argparse.RawDescriptionHelpFormatter
|
|
391
|
+
|
|
392
|
+
p = subparsers.add_parser(
|
|
393
|
+
"usage", aliases=["u"], help="API usage and spend analytics",
|
|
394
|
+
formatter_class=F,
|
|
395
|
+
epilog=f"""examples:
|
|
396
|
+
escli usage {DOT} summary, last 30 days
|
|
397
|
+
escli usage --days 7 {DOT} last 7 days
|
|
398
|
+
escli usage --service parallel {DOT} per-service detail
|
|
399
|
+
escli usage --daily {DOT} day-by-day breakdown
|
|
400
|
+
escli usage --daily --days 14 {DOT} 14-day daily breakdown
|
|
401
|
+
escli usage --pricing {DOT} current pricing rules
|
|
402
|
+
escli --json usage {DOT} structured JSON output
|
|
403
|
+
|
|
404
|
+
modes (mutually informative):
|
|
405
|
+
(default) summary across all services
|
|
406
|
+
--service NAME filter to one service (uses /by-service endpoint)
|
|
407
|
+
--daily day-by-day breakdown (uses /by-day endpoint)
|
|
408
|
+
--pricing list pricing rules (ignores --days and --service)
|
|
409
|
+
|
|
410
|
+
agent example:
|
|
411
|
+
escli --json --quiet usage --days 30
|
|
412
|
+
{ARROW} {{"success": true, "days": 30, "start": "...", "end": "...",
|
|
413
|
+
"total_requests": 1234, "total_cost_usd": 4.56,
|
|
414
|
+
"by_service": [...]}}
|
|
415
|
+
|
|
416
|
+
auth: requires `escli auth login` (uses CLI token, not API key).
|
|
417
|
+
""")
|
|
418
|
+
p.add_argument("--days", type=int, default=30, metavar="N",
|
|
419
|
+
help="Window size in days (default: 30)")
|
|
420
|
+
p.add_argument("--service", default=None, metavar="NAME",
|
|
421
|
+
help="Filter to a single service (e.g. parallel, openai, assemblyai)")
|
|
422
|
+
p.add_argument("--daily", action="store_true",
|
|
423
|
+
help="Show day-by-day breakdown")
|
|
424
|
+
p.add_argument("--pricing", action="store_true",
|
|
425
|
+
help="Show current pricing rules (ignores --days/--service)")
|
|
426
|
+
p.set_defaults(func=cmd_usage)
|
|
427
|
+
|
|
428
|
+
return p
|
|
File without changes
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|