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.
@@ -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