scorchmark 0.6.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.
- adapters.py +131 -0
- alerts.py +136 -0
- cli.py +318 -0
- detectors.py +520 -0
- ingest.py +157 -0
- licensing.py +141 -0
- pricing.py +125 -0
- pricing_drift.py +144 -0
- scorchmark-0.6.0.dist-info/METADATA +305 -0
- scorchmark-0.6.0.dist-info/RECORD +14 -0
- scorchmark-0.6.0.dist-info/WHEEL +4 -0
- scorchmark-0.6.0.dist-info/entry_points.txt +3 -0
- scorchmark-0.6.0.dist-info/licenses/LICENSE +21 -0
- server.py +392 -0
adapters.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
adapters.py — turn logs you ALREADY HAVE into Scorchmark rows.
|
|
3
|
+
|
|
4
|
+
The native Scorchmark schema (see ingest.py) is the canonical input, but almost
|
|
5
|
+
nobody has a log in exactly that shape sitting around. The single biggest source
|
|
6
|
+
of friction is "first, reformat your log." This module removes it: point Scorchmark
|
|
7
|
+
at the logs your tools already write and it adapts them on the way in.
|
|
8
|
+
|
|
9
|
+
Currently supported source formats (auto-detected):
|
|
10
|
+
- "scorchmark" — the native schema (passthrough)
|
|
11
|
+
- "claude-code" — Claude Code session transcripts (~/.claude/projects/**/*.jsonl)
|
|
12
|
+
|
|
13
|
+
Each adapter yields RAW row dicts in the native schema; ingest.normalize_row then
|
|
14
|
+
validates + prices them, so adapters stay small and the pricing/cost logic lives in
|
|
15
|
+
exactly one place.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import glob as _glob
|
|
22
|
+
|
|
23
|
+
# Default location Claude Code writes its session transcripts.
|
|
24
|
+
CLAUDE_CODE_LOG_DIR = os.path.expanduser("~/.claude/projects")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def detect_format(text: str) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Sniff the source format from the first usable JSON line.
|
|
30
|
+
|
|
31
|
+
Claude Code transcripts are tagged objects with a top-level "type" and a nested
|
|
32
|
+
"message" carrying "usage"; the native schema has flat token fields. Returns
|
|
33
|
+
"claude-code" or "scorchmark" (the safe default — ingest skips rows it can't use).
|
|
34
|
+
"""
|
|
35
|
+
for line in text.splitlines():
|
|
36
|
+
line = line.strip()
|
|
37
|
+
if not line:
|
|
38
|
+
continue
|
|
39
|
+
try:
|
|
40
|
+
obj = json.loads(line)
|
|
41
|
+
except json.JSONDecodeError:
|
|
42
|
+
continue
|
|
43
|
+
if not isinstance(obj, dict):
|
|
44
|
+
continue
|
|
45
|
+
# Claude Code event envelope: {"type": "...", "message": {...}, "timestamp": ...}
|
|
46
|
+
if "type" in obj and isinstance(obj.get("message"), dict):
|
|
47
|
+
return "claude-code"
|
|
48
|
+
# Native schema: flat token counts on the row itself.
|
|
49
|
+
if "input_tokens" in obj or "output_tokens" in obj or "cost_usd" in obj:
|
|
50
|
+
return "scorchmark"
|
|
51
|
+
# Keep looking; a stray line shouldn't decide the format.
|
|
52
|
+
return "scorchmark"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def from_claude_code(text: str):
|
|
56
|
+
"""
|
|
57
|
+
Yield native rows from Claude Code session JSONL.
|
|
58
|
+
|
|
59
|
+
Only "assistant" events carry token usage. Each becomes one priced row:
|
|
60
|
+
agent_id = "cc:<last-8-of-sessionId>" → keeps a session's loop together so
|
|
61
|
+
detect_cache_waste sees the wake-up intervals (and not the long
|
|
62
|
+
gaps *between* sessions, which would be false TTL misses).
|
|
63
|
+
provider = "anthropic" (Claude Code is Anthropic-only)
|
|
64
|
+
cache_* = Anthropic's native cache_creation_/cache_read_input_tokens.
|
|
65
|
+
"""
|
|
66
|
+
for line in text.splitlines():
|
|
67
|
+
line = line.strip()
|
|
68
|
+
if not line:
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
obj = json.loads(line)
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
continue
|
|
74
|
+
if not isinstance(obj, dict) or obj.get("type") != "assistant":
|
|
75
|
+
continue
|
|
76
|
+
msg = obj.get("message")
|
|
77
|
+
if not isinstance(msg, dict):
|
|
78
|
+
continue
|
|
79
|
+
usage = msg.get("usage")
|
|
80
|
+
if not isinstance(usage, dict):
|
|
81
|
+
continue
|
|
82
|
+
session = str(obj.get("sessionId") or "")
|
|
83
|
+
agent = f"cc:{session[-8:]}" if session else "cc:default"
|
|
84
|
+
yield {
|
|
85
|
+
"request_id": obj.get("requestId") or obj.get("uuid") or "",
|
|
86
|
+
"ts": obj.get("timestamp"),
|
|
87
|
+
"provider": "anthropic",
|
|
88
|
+
"model": msg.get("model") or "",
|
|
89
|
+
"agent_id": agent,
|
|
90
|
+
"input_tokens": usage.get("input_tokens") or 0,
|
|
91
|
+
"output_tokens": usage.get("output_tokens") or 0,
|
|
92
|
+
"cache_write_tokens": usage.get("cache_creation_input_tokens") or 0,
|
|
93
|
+
"cache_read_tokens": usage.get("cache_read_input_tokens") or 0,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Map of format name → raw-row generator. "scorchmark" is handled by ingest directly.
|
|
98
|
+
_ADAPTERS = {"claude-code": from_claude_code}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def to_raw_rows(text: str, fmt: str = "auto"):
|
|
102
|
+
"""
|
|
103
|
+
Return a list of native raw-row dicts for the given source text.
|
|
104
|
+
|
|
105
|
+
fmt="auto" sniffs the format; an explicit fmt forces an adapter. The native
|
|
106
|
+
"scorchmark" format returns an empty list here — the caller (ingest.parse) parses
|
|
107
|
+
it directly so cost/normalization isn't duplicated.
|
|
108
|
+
"""
|
|
109
|
+
if fmt == "auto":
|
|
110
|
+
fmt = detect_format(text)
|
|
111
|
+
if fmt in _ADAPTERS:
|
|
112
|
+
return list(_ADAPTERS[fmt](text))
|
|
113
|
+
return [] # native; ingest handles it
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def read_source(path: str) -> str:
|
|
117
|
+
"""
|
|
118
|
+
Read a log source into text. Accepts a file, '-' (stdin), or a DIRECTORY of
|
|
119
|
+
.jsonl files (e.g. ~/.claude/projects) — directories are read recursively and
|
|
120
|
+
concatenated, which is how Claude Code stores one file per session.
|
|
121
|
+
"""
|
|
122
|
+
import sys
|
|
123
|
+
if path in ("-", ""):
|
|
124
|
+
return sys.stdin.read()
|
|
125
|
+
path = os.path.expanduser(path)
|
|
126
|
+
if os.path.isdir(path):
|
|
127
|
+
files = sorted(_glob.glob(os.path.join(path, "**", "*.jsonl"), recursive=True))
|
|
128
|
+
if not files:
|
|
129
|
+
raise FileNotFoundError(f"no .jsonl files under {path}")
|
|
130
|
+
return "\n".join(open(f, encoding="utf-8", errors="replace").read() for f in files)
|
|
131
|
+
return open(path, encoding="utf-8", errors="replace").read()
|
alerts.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
alerts.py — structured alert payloads for Scorchmark.
|
|
3
|
+
|
|
4
|
+
Converts a check_budget result (or any detector result with a 'status' field)
|
|
5
|
+
into a structured alert payload when the status is not 'ok'. The payload is
|
|
6
|
+
designed for webhook delivery — the caller (or host agent) is responsible for
|
|
7
|
+
POSTing it; we provide a best-effort helper using stdlib urllib so there is
|
|
8
|
+
zero new runtime dependency.
|
|
9
|
+
|
|
10
|
+
Design rationale: MCP tools are stateless per-call, so we cannot run a
|
|
11
|
+
background loop. The recommended pattern is:
|
|
12
|
+
1. Call check_budget (or any detector) on each agent turn / scheduled ping.
|
|
13
|
+
2. Pass the result to build_alert.
|
|
14
|
+
3. If alert["should_fire"] is True, POST alert["payload"] to your webhook,
|
|
15
|
+
or use notify_webhook() if you have a URL configured.
|
|
16
|
+
|
|
17
|
+
No httpx, no requests. stdlib urllib.request only — and only when called explicitly.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Severity map: detector status → alert level.
|
|
27
|
+
_LEVEL: dict[str, str] = {
|
|
28
|
+
"ok": "info",
|
|
29
|
+
"warn": "warning",
|
|
30
|
+
"warning": "warning",
|
|
31
|
+
"breach": "critical",
|
|
32
|
+
"critical":"critical",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_alert(
|
|
37
|
+
check_result: dict[str, Any],
|
|
38
|
+
webhook_url: str | None = None,
|
|
39
|
+
source: str = "scorchmark",
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
"""
|
|
42
|
+
Build a structured alert payload from any detector result dict.
|
|
43
|
+
|
|
44
|
+
A result with status == 'ok' returns {should_fire: False}. Everything
|
|
45
|
+
else returns a full payload with severity, message, and context — ready
|
|
46
|
+
to POST to Slack / PagerDuty / ntfy / any JSON webhook.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
check_result: the dict returned by check_budget, detect_spend_acceleration,
|
|
50
|
+
predict_rate_limit, or any detector.
|
|
51
|
+
webhook_url: if provided, the payload includes the target URL (the
|
|
52
|
+
caller decides whether to actually POST it — see
|
|
53
|
+
notify_webhook() below for an optional helper).
|
|
54
|
+
source: label for the alert source (used as title prefix).
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
{
|
|
58
|
+
should_fire: bool,
|
|
59
|
+
level: 'info'|'warning'|'critical',
|
|
60
|
+
payload: { title, message, level, ts_utc, context, source },
|
|
61
|
+
webhook_url: str | None,
|
|
62
|
+
}
|
|
63
|
+
"""
|
|
64
|
+
status = str(check_result.get("status", "ok")).lower()
|
|
65
|
+
level = _LEVEL.get(status, "warning")
|
|
66
|
+
should_fire = status != "ok"
|
|
67
|
+
|
|
68
|
+
if not should_fire:
|
|
69
|
+
return {"should_fire": False, "level": "info", "payload": None, "webhook_url": webhook_url}
|
|
70
|
+
|
|
71
|
+
# Build a human-readable message from the most useful fields.
|
|
72
|
+
msg_parts = []
|
|
73
|
+
if "spent_usd" in check_result:
|
|
74
|
+
msg_parts.append(f"Spent: ${check_result['spent_usd']:.4f}")
|
|
75
|
+
if "monthly_cap_usd" in check_result:
|
|
76
|
+
msg_parts.append(f"Cap: ${check_result['monthly_cap_usd']:.2f}")
|
|
77
|
+
if "burn_rate_usd_per_hr" in check_result:
|
|
78
|
+
msg_parts.append(f"Burn: ${check_result['burn_rate_usd_per_hr']:.4f}/hr")
|
|
79
|
+
if "projected_to_reset_usd" in check_result:
|
|
80
|
+
msg_parts.append(f"Projected: ${check_result['projected_to_reset_usd']:.4f}")
|
|
81
|
+
if "eta_to_cap_hours" in check_result and check_result["eta_to_cap_hours"] is not None:
|
|
82
|
+
msg_parts.append(f"ETA to cap: {check_result['eta_to_cap_hours']:.1f}h")
|
|
83
|
+
if "recommendation" in check_result:
|
|
84
|
+
msg_parts.append(check_result["recommendation"])
|
|
85
|
+
if "accelerating" in check_result and check_result["accelerating"]:
|
|
86
|
+
msg_parts.append(f"Acceleration factor: {check_result.get('factor', '?')}x")
|
|
87
|
+
|
|
88
|
+
message = " | ".join(msg_parts) if msg_parts else f"Status: {status}"
|
|
89
|
+
title = f"[{source}] {level.upper()}: agent spend alert"
|
|
90
|
+
|
|
91
|
+
payload = {
|
|
92
|
+
"title": title,
|
|
93
|
+
"message": message,
|
|
94
|
+
"level": level,
|
|
95
|
+
"status": status,
|
|
96
|
+
"ts_utc": datetime.now(timezone.utc).isoformat(),
|
|
97
|
+
"context": check_result,
|
|
98
|
+
"source": source,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"should_fire": True,
|
|
103
|
+
"level": level,
|
|
104
|
+
"payload": payload,
|
|
105
|
+
"webhook_url": webhook_url,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def notify_webhook(alert: dict[str, Any]) -> dict[str, Any]:
|
|
110
|
+
"""
|
|
111
|
+
Optionally POST the alert payload to the configured webhook_url.
|
|
112
|
+
|
|
113
|
+
Uses stdlib urllib.request — no new deps. Returns {sent, status_code, error}.
|
|
114
|
+
Call this only if you want the guard itself to push notifications; otherwise
|
|
115
|
+
read alert["payload"] and route it yourself (e.g. from the host agent).
|
|
116
|
+
|
|
117
|
+
Silently returns {sent: False} if should_fire is False or webhook_url is None.
|
|
118
|
+
"""
|
|
119
|
+
if not alert.get("should_fire") or not alert.get("webhook_url"):
|
|
120
|
+
return {"sent": False, "status_code": None, "error": None}
|
|
121
|
+
|
|
122
|
+
import urllib.request # noqa: PLC0415 — intentional lazy import (stdlib only)
|
|
123
|
+
|
|
124
|
+
url = alert["webhook_url"]
|
|
125
|
+
body = json.dumps(alert["payload"]).encode("utf-8")
|
|
126
|
+
req = urllib.request.Request(
|
|
127
|
+
url,
|
|
128
|
+
data=body,
|
|
129
|
+
headers={"Content-Type": "application/json", "User-Agent": "ScorchmarkGuard/1.1"},
|
|
130
|
+
method="POST",
|
|
131
|
+
)
|
|
132
|
+
try:
|
|
133
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
134
|
+
return {"sent": True, "status_code": resp.status, "error": None}
|
|
135
|
+
except Exception as exc: # noqa: BLE001
|
|
136
|
+
return {"sent": False, "status_code": None, "error": str(exc)}
|
cli.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
scorchmark — run Scorchmark checks on a cost log straight from the terminal,
|
|
3
|
+
no MCP client required. The zero-friction way to try it on your worst log.
|
|
4
|
+
|
|
5
|
+
scorchmark report log.jsonl --cap 100 # everything at once (default)
|
|
6
|
+
scorchmark budget log.jsonl --cap 100
|
|
7
|
+
scorchmark cache-waste log.jsonl
|
|
8
|
+
scorchmark by-agent log.jsonl
|
|
9
|
+
scorchmark swap log.jsonl --to claude-haiku-4-5
|
|
10
|
+
cat log.jsonl | scorchmark report - --cap 100 # read stdin with '-'
|
|
11
|
+
|
|
12
|
+
Add --json to any command for the raw machine-readable result.
|
|
13
|
+
Same engine as the MCP server (ingest + detectors); pure stdlib.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
import ingest
|
|
23
|
+
import detectors
|
|
24
|
+
import licensing
|
|
25
|
+
|
|
26
|
+
_COLOR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
|
27
|
+
_CODES = {"red": "31", "green": "32", "yellow": "33", "violet": "35", "dim": "2", "bold": "1"}
|
|
28
|
+
_STATUS_COLOR = {"ok": "green", "warn": "yellow", "warning": "yellow",
|
|
29
|
+
"breach": "red", "critical": "red"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _c(name: str, s: str) -> str:
|
|
33
|
+
return f"\033[{_CODES[name]}m{s}\033[0m" if _COLOR and name in _CODES else s
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _money(x: float) -> str:
|
|
37
|
+
return f"${x:,.2f}" if abs(x) >= 0.01 or x == 0 else f"${x:.4f}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _bar(frac: float, width: int = 28, status: str = "ok") -> str:
|
|
41
|
+
frac = max(0.0, min(frac, 1.0))
|
|
42
|
+
filled = int(round(frac * width))
|
|
43
|
+
return _c(_STATUS_COLOR.get(status, "green"), "█" * filled) + _c("dim", "░" * (width - filled))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _badge(status: str) -> str:
|
|
47
|
+
return _c(_STATUS_COLOR.get(status, "green"), f"[{status.upper()}]")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load(path: str, fmt: str = "auto"):
|
|
51
|
+
import adapters
|
|
52
|
+
text = adapters.read_source(path)
|
|
53
|
+
return ingest.parse(text, fmt)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _emit(result, as_json: bool, printer):
|
|
57
|
+
if as_json:
|
|
58
|
+
print(json.dumps(result, indent=2))
|
|
59
|
+
else:
|
|
60
|
+
printer(result)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- human-readable printers ------------------------------------------------
|
|
64
|
+
def _print_budget(r):
|
|
65
|
+
cap = r["monthly_cap_usd"]
|
|
66
|
+
frac = r["spent_usd"] / cap if cap > 0 else 0.0
|
|
67
|
+
print(f" {_bar(frac, status=r['status'])} {_badge(r['status'])}")
|
|
68
|
+
print(f" spent {_c('bold', _money(r['spent_usd']))} of {_money(cap)} cap"
|
|
69
|
+
f" · burn rate {_money(r['burn_rate_usd_per_hr'])}/hr")
|
|
70
|
+
# "projected" is a straight-line extrapolation of the burn rate, not a forecast —
|
|
71
|
+
# say "at this pace" so a high number from a short sample doesn't read as a prediction.
|
|
72
|
+
if r.get("eta_to_cap_hours") is not None:
|
|
73
|
+
print(_c("dim", f" at this pace → hits the cap in ~{r['eta_to_cap_hours']}h "
|
|
74
|
+
f"(≈{_money(r['projected_to_reset_usd'])} by reset day)"))
|
|
75
|
+
else:
|
|
76
|
+
print(_c("dim", f" at this pace → ≈{_money(r['projected_to_reset_usd'])} by reset day"))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _print_cache_waste(r):
|
|
80
|
+
if not r["ttl_miss_count"]:
|
|
81
|
+
print(_c("green", " no systematic cache waste in window"))
|
|
82
|
+
return
|
|
83
|
+
print(f" {_c('red', _money(r['wasted_cost_usd']) + ' wasted')} across "
|
|
84
|
+
f"{r['ttl_miss_count']} rebuild(s) · loop ~{int(r['median_interval_s'])}s "
|
|
85
|
+
f"vs {r['cache_ttl_s']}s TTL ({r['cache_model']})")
|
|
86
|
+
print(_c("dim", f" {r['recommendation']}"))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _print_by_agent(rows):
|
|
90
|
+
if not rows:
|
|
91
|
+
print(_c("dim", " no spend in window"))
|
|
92
|
+
return
|
|
93
|
+
for a in rows:
|
|
94
|
+
share = a["share_of_total"] * 100
|
|
95
|
+
name = a["agent_id"]
|
|
96
|
+
req = _c("dim", f"({a['requests']} req)")
|
|
97
|
+
print(f" {_bar(a['share_of_total'], width=20)} {share:5.1f}% "
|
|
98
|
+
f"{name:<20} {_money(a['cost_usd'])} {req}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _print_anomalies(rows):
|
|
102
|
+
if not rows:
|
|
103
|
+
print(_c("green", " no spend anomalies in window"))
|
|
104
|
+
return
|
|
105
|
+
for a in rows:
|
|
106
|
+
x = _c("yellow", f"{a['x_over_agent_median']}x")
|
|
107
|
+
loc = f"{a['agent_id']}/{a['request_id']}"
|
|
108
|
+
print(f" {x} {loc} {_money(a['cost_usd'])} {_c('dim', a['reason'])}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _print_swap(r):
|
|
112
|
+
saved = _c("green", f"{_money(r['savings_usd'])} / {r['savings_pct']}% saved")
|
|
113
|
+
print(f" swap → {_c('bold', r['to_model'])}: {_money(r['current_usd'])} would be "
|
|
114
|
+
f"{_money(r['simulated_usd'])} ({saved})")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _print_gate(g):
|
|
118
|
+
print(f" {_c('yellow', '🔒 ' + g['tier_required'].upper())} {g['message']}")
|
|
119
|
+
print(_c("dim", f" unlock: {g['upgrade_url']}"))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _print_license(st):
|
|
123
|
+
col = "green" if st["valid"] else "dim"
|
|
124
|
+
print(f" tier: {_c(col, st['tier'].upper())} · {st['reason']}")
|
|
125
|
+
if st.get("email"):
|
|
126
|
+
print(_c("dim", f" licensed to {st['email']} · expires {st.get('expires')}"))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _section(title):
|
|
130
|
+
print()
|
|
131
|
+
print(_c("violet", _c("bold", f"▸ {title}")))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _dur(seconds: float) -> str:
|
|
135
|
+
s = max(0, int(seconds))
|
|
136
|
+
if s < 90:
|
|
137
|
+
return f"{s}s"
|
|
138
|
+
if s < 5400:
|
|
139
|
+
return f"{s/60:.0f} min"
|
|
140
|
+
if s < 172800:
|
|
141
|
+
return f"{s/3600:.1f}h"
|
|
142
|
+
return f"{s/86400:.1f}d"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _print_headline(rows, args):
|
|
146
|
+
"""The 'so what' up top: total, the waste found (free), and the savings available."""
|
|
147
|
+
total = sum(r["cost_usd"] for r in rows)
|
|
148
|
+
span = (rows[-1]["ts"] - rows[0]["ts"]) if len(rows) > 1 else 0
|
|
149
|
+
span_txt = f" · over {_dur(span)}" if span else ""
|
|
150
|
+
print(f" {_c('bold', _money(total))} spent across {len(rows)} requests{span_txt}")
|
|
151
|
+
cw = detectors.detect_cache_waste(rows, args.window)
|
|
152
|
+
if cw["ttl_miss_count"]:
|
|
153
|
+
pct = (100 * cw["wasted_cost_usd"] / total) if total else 0
|
|
154
|
+
print(" " + _c("red", f"💸 {_money(cw['wasted_cost_usd'])} wasted on cache rebuilds")
|
|
155
|
+
+ _c("dim", f" ({pct:.0f}% of spend — recoverable)"))
|
|
156
|
+
else:
|
|
157
|
+
print(" " + _c("green", "✓ no cache-rebuild waste found"))
|
|
158
|
+
g = licensing.gate("team") # model-swap savings are Team
|
|
159
|
+
if g:
|
|
160
|
+
print(" " + _c("dim", "🔒 model-swap savings hidden — Team tier"))
|
|
161
|
+
else:
|
|
162
|
+
sw = detectors.simulate_model_swap(rows, args.to, None, args.window)
|
|
163
|
+
if sw["savings_usd"] > 0.005:
|
|
164
|
+
print(" " + _c("green", f"💡 {_money(sw['savings_usd'])} saveable")
|
|
165
|
+
+ _c("dim", f" by switching to {args.to} ({sw['savings_pct']:.0f}% less)"))
|
|
166
|
+
else:
|
|
167
|
+
print(" " + _c("dim", f" no model-swap savings vs {args.to} here"))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --- commands ---------------------------------------------------------------
|
|
171
|
+
def _cmd_budget(rows, args):
|
|
172
|
+
return detectors.check_budget(rows, args.cap, args.reset_day, args.window), _print_budget
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _cmd_cache(rows, args):
|
|
176
|
+
return detectors.detect_cache_waste(rows, args.window), _print_cache_waste
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _cmd_agent(rows, args):
|
|
180
|
+
g = licensing.gate("team") # per-agent attribution is Team
|
|
181
|
+
if g:
|
|
182
|
+
return g, _print_gate
|
|
183
|
+
return detectors.cost_by_agent(rows, args.window), _print_by_agent
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _cmd_anomalies(rows, args):
|
|
187
|
+
return detectors.find_spend_anomalies(rows, args.threshold, args.window), _print_anomalies
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _cmd_swap(rows, args):
|
|
191
|
+
g = licensing.gate("team") # model-swap simulator is Team
|
|
192
|
+
if g:
|
|
193
|
+
return g, _print_gate
|
|
194
|
+
return detectors.simulate_model_swap(rows, args.to, None, args.window), _print_swap
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _cmd_report(rows, args):
|
|
198
|
+
# Composite human view; --json emits one combined object. Premium sections are
|
|
199
|
+
# gated (replaced by the upgrade-required object) exactly like the MCP server.
|
|
200
|
+
def _g(tier, fn):
|
|
201
|
+
return licensing.gate(tier) or fn()
|
|
202
|
+
|
|
203
|
+
if args.json:
|
|
204
|
+
return {
|
|
205
|
+
"license": licensing.status(),
|
|
206
|
+
"summary": {
|
|
207
|
+
"requests": len(rows),
|
|
208
|
+
"total_spend_usd": round(sum(r["cost_usd"] for r in rows), 4),
|
|
209
|
+
},
|
|
210
|
+
"cache_waste": detectors.detect_cache_waste(rows, args.window),
|
|
211
|
+
"model_swap": _g("team", lambda: detectors.simulate_model_swap(rows, args.to, None, args.window)),
|
|
212
|
+
"budget": detectors.check_budget(rows, args.cap, args.reset_day, args.window),
|
|
213
|
+
"acceleration": _g("pro", lambda: detectors.detect_spend_acceleration(rows, args.window)),
|
|
214
|
+
"cost_by_agent": _g("team", lambda: detectors.cost_by_agent(rows, args.window)),
|
|
215
|
+
"anomalies": detectors.find_spend_anomalies(rows, args.threshold, args.window),
|
|
216
|
+
}, (lambda r: print(json.dumps(r, indent=2)))
|
|
217
|
+
|
|
218
|
+
def printer(_):
|
|
219
|
+
# Lead with the findings (what you came for), then the breakdowns.
|
|
220
|
+
_section("Bottom line")
|
|
221
|
+
_print_headline(rows, args)
|
|
222
|
+
_section("Cache waste")
|
|
223
|
+
_print_cache_waste(detectors.detect_cache_waste(rows, args.window))
|
|
224
|
+
_section(f"Savings — swap → {args.to} (Team)")
|
|
225
|
+
gt = licensing.gate("team")
|
|
226
|
+
if gt:
|
|
227
|
+
_print_gate(gt)
|
|
228
|
+
else:
|
|
229
|
+
_print_swap(detectors.simulate_model_swap(rows, args.to, None, args.window))
|
|
230
|
+
_section(f"Budget (cap {_money(args.cap)})")
|
|
231
|
+
_print_budget(detectors.check_budget(rows, args.cap, args.reset_day, args.window))
|
|
232
|
+
_section("Spend acceleration (Pro)")
|
|
233
|
+
gp = licensing.gate("pro")
|
|
234
|
+
if gp:
|
|
235
|
+
_print_gate(gp)
|
|
236
|
+
else:
|
|
237
|
+
acc = detectors.detect_spend_acceleration(rows, args.window)
|
|
238
|
+
print(f" {_badge('warn' if acc['accelerating'] else 'ok')} {_c('dim', acc['recommendation'])}")
|
|
239
|
+
_section("Cost by agent (Team)")
|
|
240
|
+
gt2 = licensing.gate("team")
|
|
241
|
+
if gt2:
|
|
242
|
+
_print_gate(gt2)
|
|
243
|
+
else:
|
|
244
|
+
_print_by_agent(detectors.cost_by_agent(rows, args.window))
|
|
245
|
+
_section("Anomalies")
|
|
246
|
+
_print_anomalies(detectors.find_spend_anomalies(rows, args.threshold, args.window))
|
|
247
|
+
return None, printer
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _build_parser():
|
|
251
|
+
p = argparse.ArgumentParser(prog="scorchmark", description=__doc__.strip().splitlines()[1])
|
|
252
|
+
p.add_argument("--version", action="version", version="scorchmark 0.6.0")
|
|
253
|
+
sub = p.add_subparsers(dest="cmd")
|
|
254
|
+
|
|
255
|
+
def add(name, fn, help_):
|
|
256
|
+
sp = sub.add_parser(name, help=help_)
|
|
257
|
+
sp.add_argument("log", nargs="?", default="-",
|
|
258
|
+
help="cost-log file, a directory of .jsonl, or '-' for stdin")
|
|
259
|
+
sp.add_argument("--window", default="30d", help="lookback window (e.g. 24h, 7d, 30d)")
|
|
260
|
+
sp.add_argument("--from", dest="fmt", default="auto",
|
|
261
|
+
choices=["auto", "scorchmark", "claude-code"],
|
|
262
|
+
help="source log format (default: auto-detect)")
|
|
263
|
+
sp.add_argument("--claude-code", action="store_true",
|
|
264
|
+
help="analyze your own Claude Code logs (~/.claude/projects)")
|
|
265
|
+
sp.add_argument("--json", action="store_true", help="raw JSON output")
|
|
266
|
+
sp.set_defaults(_fn=fn)
|
|
267
|
+
return sp
|
|
268
|
+
|
|
269
|
+
sp = add("report", _cmd_report, "run all checks (default)")
|
|
270
|
+
sp.add_argument("--cap", type=float, default=100.0); sp.add_argument("--reset-day", type=int, default=1)
|
|
271
|
+
sp.add_argument("--threshold", type=float, default=3.0)
|
|
272
|
+
sp.add_argument("--to", default="claude-haiku-4-5",
|
|
273
|
+
help="model to price the swap-savings estimate against (default: claude-haiku-4-5)")
|
|
274
|
+
sp = add("budget", _cmd_budget, "live budget tripwire")
|
|
275
|
+
sp.add_argument("--cap", type=float, required=True); sp.add_argument("--reset-day", type=int, default=1)
|
|
276
|
+
add("cache-waste", _cmd_cache, "detect cache-TTL waste")
|
|
277
|
+
add("by-agent", _cmd_agent, "per-agent cost attribution")
|
|
278
|
+
sp = add("anomalies", _cmd_anomalies, "flag spend spikes"); sp.add_argument("--threshold", type=float, default=3.0)
|
|
279
|
+
sp = add("swap", _cmd_swap, "simulate a model swap (Team)"); sp.add_argument("--to", required=True, help="target model, e.g. claude-haiku-4-5")
|
|
280
|
+
lp = sub.add_parser("license", help="show active license tier (SCORCHMARK_LICENSE)")
|
|
281
|
+
lp.add_argument("--json", action="store_true", help="raw JSON output") # handled in main()
|
|
282
|
+
return p
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def main(argv=None):
|
|
286
|
+
args = _build_parser().parse_args(argv)
|
|
287
|
+
if not getattr(args, "cmd", None):
|
|
288
|
+
_build_parser().print_help()
|
|
289
|
+
return 0
|
|
290
|
+
if args.cmd == "license": # no log needed
|
|
291
|
+
st = licensing.status()
|
|
292
|
+
print(json.dumps(st, indent=2)) if args.json else _print_license(st)
|
|
293
|
+
return 0
|
|
294
|
+
# --claude-code is shorthand: point at ~/.claude/projects and force the adapter.
|
|
295
|
+
if getattr(args, "claude_code", False):
|
|
296
|
+
import adapters
|
|
297
|
+
if args.log == "-":
|
|
298
|
+
args.log = adapters.CLAUDE_CODE_LOG_DIR
|
|
299
|
+
args.fmt = "claude-code"
|
|
300
|
+
try:
|
|
301
|
+
rows, summary = _load(args.log, getattr(args, "fmt", "auto"))
|
|
302
|
+
except FileNotFoundError as e:
|
|
303
|
+
print(_c("red", f"error: {e}"), file=sys.stderr)
|
|
304
|
+
return 2
|
|
305
|
+
if not rows:
|
|
306
|
+
print(_c("yellow", "no usable rows in log "
|
|
307
|
+
f"(skipped {summary['rows_skipped']})"), file=sys.stderr)
|
|
308
|
+
return 1
|
|
309
|
+
print(_c("dim", f"ingested {summary['rows_ingested']} rows · "
|
|
310
|
+
f"computed spend {_money(summary['computed_cost_usd'])}"))
|
|
311
|
+
result, printer = args._fn(rows, args)
|
|
312
|
+
_emit(result, args.json and result is not None, printer)
|
|
313
|
+
print()
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
if __name__ == "__main__":
|
|
318
|
+
sys.exit(main())
|