claude-finops 0.12.0 → 0.14.0
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.
- package/README.md +77 -3
- package/config/settings.json +9 -0
- package/finops/analytics.py +78 -2
- package/finops/api.py +142 -4
- package/finops/cloud.py +51 -3
- package/finops/guard.py +412 -0
- package/finops/integrate.py +78 -0
- package/finops/jev.py +175 -0
- package/package.json +1 -1
- package/run.py +16 -12
- package/web/app.js +1035 -192
- package/web/guide/budgets.png +0 -0
- package/web/styles.css +86 -0
package/finops/guard.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Session guard: a PreToolUse hook that keeps a human in the loop on token-heavy sessions.
|
|
2
|
+
|
|
3
|
+
claude-finops --guard invoked by Claude Code before every tool call
|
|
4
|
+
claude-finops --install-guard wire it into ~/.claude/settings.json (see integrate.py)
|
|
5
|
+
|
|
6
|
+
It counts the session's billable tokens (input + output + cache read + cache
|
|
7
|
+
write, the same figure as the dashboard's Tokens column) straight from the
|
|
8
|
+
transcript, reading only what was appended since the last call. Below the
|
|
9
|
+
budget it warns once at each configured percentage; at the budget it asks the
|
|
10
|
+
user to approve the next tool call, then again every step_pct (or never, in
|
|
11
|
+
"once" mode).
|
|
12
|
+
|
|
13
|
+
A hook cannot end a session, and this one never blocks on its own failure:
|
|
14
|
+
anything unexpected means exit 0 with no output, so Claude Code carries on as
|
|
15
|
+
if the guard were not installed.
|
|
16
|
+
"""
|
|
17
|
+
import glob
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
from .paths import DATA_DIR, SETTINGS_PATH, LOCAL_SETTINGS_PATH
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import fcntl
|
|
28
|
+
except ImportError: # Windows: no advisory locks, concurrent calls are rare there
|
|
29
|
+
fcntl = None
|
|
30
|
+
|
|
31
|
+
STATE_DIR = os.path.join(DATA_DIR, "guard")
|
|
32
|
+
MAX_READ = 64 * 1024 * 1024 # new bytes read per call; the rest waits for the next call
|
|
33
|
+
STALE_S = 30 * 86400
|
|
34
|
+
SWEEP_S = 3600
|
|
35
|
+
SESSION_KEEP_S = 30 * 86400 # a per-session limit is forgotten after this long
|
|
36
|
+
SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
|
37
|
+
REJECTED = ("User rejected tool use", "The user doesn't want to proceed with this tool use")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------- settings ----
|
|
41
|
+
|
|
42
|
+
def _num(v):
|
|
43
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_guard(g, default):
|
|
47
|
+
"""Clean a guard block. Returns (cleaned, [bad paths]); bad leaves use the default."""
|
|
48
|
+
default = default or {}
|
|
49
|
+
if not isinstance(g, dict):
|
|
50
|
+
return json.loads(json.dumps(default)), (["guard"] if g is not None else [])
|
|
51
|
+
out, bad = dict(default), []
|
|
52
|
+
for k, v in g.items():
|
|
53
|
+
if k.startswith("_"):
|
|
54
|
+
out[k] = v
|
|
55
|
+
elif k == "session_tokens":
|
|
56
|
+
if v is None or (_num(v) and v > 0):
|
|
57
|
+
out[k] = v
|
|
58
|
+
else:
|
|
59
|
+
bad.append(f"guard.{k}")
|
|
60
|
+
elif k == "warn_pct":
|
|
61
|
+
if isinstance(v, list) and all(_num(x) and 0 < x < 100 for x in v):
|
|
62
|
+
out[k] = sorted(set(v))
|
|
63
|
+
else:
|
|
64
|
+
bad.append(f"guard.{k}")
|
|
65
|
+
elif k == "after_approval":
|
|
66
|
+
if v in ("step", "once"):
|
|
67
|
+
out[k] = v
|
|
68
|
+
else:
|
|
69
|
+
bad.append(f"guard.{k}")
|
|
70
|
+
elif k == "step_pct":
|
|
71
|
+
if _num(v) and 1 <= v <= 1000:
|
|
72
|
+
out[k] = v
|
|
73
|
+
else:
|
|
74
|
+
bad.append(f"guard.{k}")
|
|
75
|
+
elif k == "sessions":
|
|
76
|
+
if not isinstance(v, dict):
|
|
77
|
+
bad.append(f"guard.{k}")
|
|
78
|
+
continue
|
|
79
|
+
sessions = {}
|
|
80
|
+
for sid, o in v.items():
|
|
81
|
+
keep = {"set_at": o["set_at"]} if isinstance(o, dict) and isinstance(o.get("set_at"), str) else {}
|
|
82
|
+
if not SAFE_ID.match(str(sid)):
|
|
83
|
+
bad.append(f"guard.sessions.{sid}")
|
|
84
|
+
elif isinstance(o, dict) and o.get("off") is True:
|
|
85
|
+
sessions[sid] = dict(keep, off=True)
|
|
86
|
+
elif isinstance(o, dict) and _num(o.get("session_tokens")) and o["session_tokens"] > 0:
|
|
87
|
+
sessions[sid] = dict(keep, session_tokens=o["session_tokens"])
|
|
88
|
+
else:
|
|
89
|
+
bad.append(f"guard.sessions.{sid}")
|
|
90
|
+
out[k] = sessions
|
|
91
|
+
elif k == "projects":
|
|
92
|
+
if not isinstance(v, dict):
|
|
93
|
+
bad.append(f"guard.{k}")
|
|
94
|
+
continue
|
|
95
|
+
projects = {}
|
|
96
|
+
for path, o in v.items():
|
|
97
|
+
if isinstance(o, dict) and o.get("off") is True:
|
|
98
|
+
projects[path] = {"off": True}
|
|
99
|
+
elif isinstance(o, dict) and _num(o.get("session_tokens")) and o["session_tokens"] > 0:
|
|
100
|
+
projects[path] = {"session_tokens": o["session_tokens"]}
|
|
101
|
+
else:
|
|
102
|
+
bad.append(f"guard.projects.{path}")
|
|
103
|
+
out[k] = projects
|
|
104
|
+
else:
|
|
105
|
+
out[k] = v
|
|
106
|
+
return out, bad
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_guard_settings():
|
|
110
|
+
"""The guard block only: shipped default + settings.local.json, validated.
|
|
111
|
+
|
|
112
|
+
Deliberately not analytics.load_settings(): that also detects the account
|
|
113
|
+
from ~/.claude.json, which the hook has no use for on every tool call.
|
|
114
|
+
"""
|
|
115
|
+
with open(SETTINGS_PATH) as fh:
|
|
116
|
+
default = json.load(fh).get("guard") or {}
|
|
117
|
+
local = {}
|
|
118
|
+
try:
|
|
119
|
+
with open(LOCAL_SETTINGS_PATH) as fh:
|
|
120
|
+
local = json.load(fh).get("guard") or {}
|
|
121
|
+
except (OSError, ValueError, AttributeError):
|
|
122
|
+
pass
|
|
123
|
+
merged = dict(default)
|
|
124
|
+
if isinstance(local, dict):
|
|
125
|
+
merged.update(local)
|
|
126
|
+
return validate_guard(merged, default)[0]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _norm(p):
|
|
130
|
+
return os.path.normcase(os.path.realpath(os.path.expanduser(p)))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def prune_sessions(sessions, now=None):
|
|
134
|
+
"""Drop per-session limits set more than 30 days ago (entries without a date stay)."""
|
|
135
|
+
now = now or time.time()
|
|
136
|
+
out = {}
|
|
137
|
+
for sid, o in (sessions or {}).items():
|
|
138
|
+
try:
|
|
139
|
+
age = now - time.mktime(time.strptime(o["set_at"], "%Y-%m-%dT%H:%M:%S"))
|
|
140
|
+
except (KeyError, TypeError, ValueError):
|
|
141
|
+
age = 0
|
|
142
|
+
if age <= SESSION_KEEP_S:
|
|
143
|
+
out[sid] = o
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def has_any_budget(cfg):
|
|
148
|
+
"""Is any per-session budget set: global, a project override, or one session's own?"""
|
|
149
|
+
return bool(cfg.get("session_tokens")) or any(
|
|
150
|
+
o.get("session_tokens") for sect in ("projects", "sessions")
|
|
151
|
+
for o in (cfg.get(sect) or {}).values())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def budget_for(cfg, cwd, session_id=None):
|
|
155
|
+
"""The budget that applies: this session's own limit, else the most specific
|
|
156
|
+
project override, else the global one."""
|
|
157
|
+
own = (cfg.get("sessions") or {}).get(session_id) if session_id else None
|
|
158
|
+
if own is not None:
|
|
159
|
+
return None if own.get("off") else own.get("session_tokens")
|
|
160
|
+
best, best_len = None, -1
|
|
161
|
+
if cwd:
|
|
162
|
+
here = _norm(cwd)
|
|
163
|
+
for path, o in (cfg.get("projects") or {}).items():
|
|
164
|
+
p = _norm(path)
|
|
165
|
+
if (here == p or here.startswith(p.rstrip(os.sep) + os.sep)) and len(p) > best_len:
|
|
166
|
+
best, best_len = o, len(p)
|
|
167
|
+
if best is not None:
|
|
168
|
+
return None if best.get("off") else best.get("session_tokens")
|
|
169
|
+
return cfg.get("session_tokens")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------- counting ----
|
|
173
|
+
|
|
174
|
+
def billable(u):
|
|
175
|
+
cc = u.get("cache_creation") or {}
|
|
176
|
+
cw = int(u.get("cache_creation_input_tokens") or 0) or (
|
|
177
|
+
int(cc.get("ephemeral_5m_input_tokens") or 0) + int(cc.get("ephemeral_1h_input_tokens") or 0))
|
|
178
|
+
return (int(u.get("input_tokens") or 0) + int(u.get("output_tokens") or 0)
|
|
179
|
+
+ int(u.get("cache_read_input_tokens") or 0) + cw)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def session_files(transcript_path, session_id):
|
|
183
|
+
"""The transcript plus its subagents' transcripts, which the dashboard counts too."""
|
|
184
|
+
files = [transcript_path]
|
|
185
|
+
sub = os.path.join(os.path.dirname(transcript_path), session_id, "subagents")
|
|
186
|
+
files += sorted(glob.glob(os.path.join(sub, "*.jsonl")))
|
|
187
|
+
return files
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _read_new(path, fs, budget_left, on_row):
|
|
191
|
+
"""Feed complete lines appended since fs["offset"] to on_row. Returns bytes read."""
|
|
192
|
+
try:
|
|
193
|
+
size = os.path.getsize(path)
|
|
194
|
+
except OSError:
|
|
195
|
+
return 0
|
|
196
|
+
if size < fs.get("offset", 0): # rewritten: start over
|
|
197
|
+
fs["offset"] = 0
|
|
198
|
+
fs.setdefault("requests", {}).clear()
|
|
199
|
+
if size == fs.get("offset", 0) or budget_left <= 0:
|
|
200
|
+
return 0
|
|
201
|
+
with open(path, "rb") as fh:
|
|
202
|
+
fh.seek(fs.get("offset", 0))
|
|
203
|
+
chunk = fh.read(min(size - fs.get("offset", 0), budget_left))
|
|
204
|
+
end = chunk.rfind(b"\n")
|
|
205
|
+
if end < 0:
|
|
206
|
+
return 0 # a partial line: wait for the rest
|
|
207
|
+
for raw in chunk[:end].split(b"\n"):
|
|
208
|
+
raw = raw.strip()
|
|
209
|
+
if not raw:
|
|
210
|
+
continue
|
|
211
|
+
try:
|
|
212
|
+
row = json.loads(raw)
|
|
213
|
+
except ValueError:
|
|
214
|
+
continue
|
|
215
|
+
if isinstance(row, dict):
|
|
216
|
+
on_row(row)
|
|
217
|
+
fs["offset"] = fs.get("offset", 0) + end + 1
|
|
218
|
+
return end + 1
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def count_session(transcript_path, session_id, state, pending_tool=None):
|
|
222
|
+
"""Update state["files"] with newly appended lines; return (total billable, tool outcome).
|
|
223
|
+
|
|
224
|
+
Each API request is counted once, from the last line carrying its usage (the
|
|
225
|
+
ETL keys requests the same way: requestId, else message.id, else uuid). The
|
|
226
|
+
outcome is "approved"/"rejected" once the result of pending_tool appears.
|
|
227
|
+
"""
|
|
228
|
+
files = state.setdefault("files", {})
|
|
229
|
+
outcome = []
|
|
230
|
+
left = MAX_READ
|
|
231
|
+
for path in session_files(transcript_path, session_id):
|
|
232
|
+
fs = files.setdefault(path, {"offset": 0, "requests": {}})
|
|
233
|
+
reqs = fs.setdefault("requests", {})
|
|
234
|
+
|
|
235
|
+
def on_row(r):
|
|
236
|
+
msg = r.get("message") if isinstance(r.get("message"), dict) else {}
|
|
237
|
+
if r.get("type") == "assistant" and isinstance(msg.get("usage"), dict):
|
|
238
|
+
key = r.get("requestId") or msg.get("id") or r.get("uuid")
|
|
239
|
+
if key:
|
|
240
|
+
reqs[key] = billable(msg["usage"])
|
|
241
|
+
elif pending_tool and r.get("type") == "user":
|
|
242
|
+
for c in msg.get("content") or []:
|
|
243
|
+
if isinstance(c, dict) and c.get("type") == "tool_result" \
|
|
244
|
+
and c.get("tool_use_id") == pending_tool:
|
|
245
|
+
text = json.dumps(c.get("content")) + json.dumps(r.get("toolUseResult"))
|
|
246
|
+
outcome.append("rejected" if any(s in text for s in REJECTED) else "approved")
|
|
247
|
+
|
|
248
|
+
left -= _read_new(path, fs, left, on_row)
|
|
249
|
+
total = sum(sum(f.get("requests", {}).values()) for f in files.values())
|
|
250
|
+
return total, (outcome[-1] if outcome else None)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def session_usage(transcript_path, session_id):
|
|
254
|
+
"""The session's billable tokens, counted like the hook does, without saving state."""
|
|
255
|
+
state = {}
|
|
256
|
+
while True:
|
|
257
|
+
before = {p: f.get("offset", 0) for p, f in state.get("files", {}).items()}
|
|
258
|
+
total, _ = count_session(transcript_path, session_id, state)
|
|
259
|
+
after = {p: f.get("offset", 0) for p, f in state.get("files", {}).items()}
|
|
260
|
+
if after == before: # nothing new read: the whole session is counted
|
|
261
|
+
return total
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def find_transcript(session_id, roots):
|
|
265
|
+
"""<root>/<project>/<session_id>.jsonl for a Claude Code session id, or None."""
|
|
266
|
+
if not SAFE_ID.match(str(session_id or "")):
|
|
267
|
+
return None
|
|
268
|
+
for root in roots:
|
|
269
|
+
hits = glob.glob(os.path.join(root, "*", f"{session_id}.jsonl"))
|
|
270
|
+
if hits:
|
|
271
|
+
return hits[0]
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------- deciding ----
|
|
276
|
+
|
|
277
|
+
def fmt_tokens(n):
|
|
278
|
+
for div, unit in ((1e9, "B"), (1e6, "M"), (1e3, "k")):
|
|
279
|
+
if n >= div:
|
|
280
|
+
s = f"{n / div:.1f}".rstrip("0").rstrip(".")
|
|
281
|
+
return f"{s}{unit}"
|
|
282
|
+
return str(int(n))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def next_ask(cfg, state):
|
|
286
|
+
approved = state.get("approved_pct")
|
|
287
|
+
if approved is None:
|
|
288
|
+
return 100
|
|
289
|
+
if cfg.get("after_approval") == "once":
|
|
290
|
+
return None
|
|
291
|
+
return approved + (cfg.get("step_pct") or 25)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def decide(total, budget, cfg, state, outcome=None, tool_use_id=None):
|
|
295
|
+
"""The hook's answer for this tool call (a dict to print), or None to stay silent."""
|
|
296
|
+
if not budget:
|
|
297
|
+
return None
|
|
298
|
+
if state.get("asked_at") is not None and outcome == "approved":
|
|
299
|
+
state["approved_pct"], state["asked_at"], state["asked_tool"] = state["asked_at"], None, None
|
|
300
|
+
elif outcome == "rejected":
|
|
301
|
+
state["asked_at"], state["asked_tool"] = None, None # declined: ask again next time
|
|
302
|
+
pct = 100.0 * total / budget
|
|
303
|
+
level = next_ask(cfg, state)
|
|
304
|
+
if level is not None and pct >= level:
|
|
305
|
+
state["asked_at"], state["asked_tool"] = level, tool_use_id
|
|
306
|
+
# the ask supersedes any warning it jumped past; don't replay them after approval
|
|
307
|
+
state["warned"] = sorted(set(state.get("warned") or [])
|
|
308
|
+
| {w for w in (cfg.get("warn_pct") or []) if pct >= w})
|
|
309
|
+
after = next_ask(cfg, dict(state, approved_pct=level))
|
|
310
|
+
hint = (f"Approving continues until {after:g}%." if after is not None
|
|
311
|
+
else "Approving turns the guard off for the rest of this session.")
|
|
312
|
+
return {"hookSpecificOutput": {
|
|
313
|
+
"hookEventName": "PreToolUse",
|
|
314
|
+
"permissionDecision": "ask",
|
|
315
|
+
"permissionDecisionReason":
|
|
316
|
+
f"Session guard: this session has used {fmt_tokens(total)} tokens, "
|
|
317
|
+
f"{pct:.0f}% of its {fmt_tokens(budget)} budget. Allow this tool call? {hint}"}}
|
|
318
|
+
warned = set(state.get("warned") or [])
|
|
319
|
+
crossed = [w for w in (cfg.get("warn_pct") or []) if pct >= w and w not in warned]
|
|
320
|
+
if crossed:
|
|
321
|
+
state["warned"] = sorted(warned | set(crossed))
|
|
322
|
+
return {"systemMessage": f"Session guard: {fmt_tokens(total)} tokens, {pct:.0f}% of "
|
|
323
|
+
f"this session's {fmt_tokens(budget)} budget.",
|
|
324
|
+
"hookSpecificOutput": {
|
|
325
|
+
"hookEventName": "PreToolUse",
|
|
326
|
+
"additionalContext":
|
|
327
|
+
f"This session has used {pct:.0f}% of its token budget. At a natural "
|
|
328
|
+
"break, suggest the user run /compact or start a fresh session."}}
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ------------------------------------------------------------------- state ----
|
|
333
|
+
|
|
334
|
+
def _state_path(session_id):
|
|
335
|
+
safe = "".join(ch for ch in session_id if ch.isalnum() or ch in "-_")
|
|
336
|
+
return os.path.join(STATE_DIR, f"{safe or 'unknown'}.json")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _load_state(path):
|
|
340
|
+
try:
|
|
341
|
+
with open(path) as fh:
|
|
342
|
+
s = json.load(fh)
|
|
343
|
+
return s if isinstance(s, dict) else {}
|
|
344
|
+
except (OSError, ValueError):
|
|
345
|
+
return {}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _save_state(path, state):
|
|
349
|
+
state["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
350
|
+
tmp = f"{path}.{os.getpid()}.tmp"
|
|
351
|
+
with open(tmp, "w") as fh:
|
|
352
|
+
json.dump(state, fh)
|
|
353
|
+
os.replace(tmp, path)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _sweep():
|
|
357
|
+
"""Drop state for sessions untouched for 30 days; at most once an hour."""
|
|
358
|
+
marker = os.path.join(STATE_DIR, ".swept")
|
|
359
|
+
now = time.time()
|
|
360
|
+
try:
|
|
361
|
+
if now - os.path.getmtime(marker) < SWEEP_S:
|
|
362
|
+
return
|
|
363
|
+
except OSError:
|
|
364
|
+
pass
|
|
365
|
+
for p in glob.glob(os.path.join(STATE_DIR, "*.json")) + glob.glob(os.path.join(STATE_DIR, "*.lock")):
|
|
366
|
+
try:
|
|
367
|
+
if now - os.path.getmtime(p) > STALE_S:
|
|
368
|
+
os.remove(p)
|
|
369
|
+
except OSError:
|
|
370
|
+
pass
|
|
371
|
+
with open(marker, "w"):
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# -------------------------------------------------------------------- hook ----
|
|
376
|
+
|
|
377
|
+
def run(payload, cfg=None):
|
|
378
|
+
"""Decide for one PreToolUse payload. Returns the dict to print, or None."""
|
|
379
|
+
session_id = payload.get("session_id")
|
|
380
|
+
transcript = payload.get("transcript_path")
|
|
381
|
+
if not session_id or not transcript:
|
|
382
|
+
return None
|
|
383
|
+
cfg = cfg if cfg is not None else load_guard_settings()
|
|
384
|
+
budget = budget_for(cfg, payload.get("cwd"), session_id)
|
|
385
|
+
if not budget:
|
|
386
|
+
return None
|
|
387
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
388
|
+
path = _state_path(session_id)
|
|
389
|
+
lock = open(path + ".lock", "w")
|
|
390
|
+
try:
|
|
391
|
+
if fcntl:
|
|
392
|
+
fcntl.flock(lock, fcntl.LOCK_EX) # parallel tool calls share one state file
|
|
393
|
+
state = _load_state(path)
|
|
394
|
+
total, outcome = count_session(transcript, session_id, state, state.get("asked_tool"))
|
|
395
|
+
out = decide(total, budget, cfg, state, outcome, payload.get("tool_use_id"))
|
|
396
|
+
_save_state(path, state)
|
|
397
|
+
finally:
|
|
398
|
+
lock.close()
|
|
399
|
+
_sweep()
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def main():
|
|
404
|
+
try:
|
|
405
|
+
raw = sys.stdin.read()
|
|
406
|
+
payload = json.loads(raw) if raw.strip() else {}
|
|
407
|
+
out = run(payload) if isinstance(payload, dict) else None
|
|
408
|
+
if out:
|
|
409
|
+
print(json.dumps(out))
|
|
410
|
+
except Exception:
|
|
411
|
+
pass # fail open: never stand between the user and their session
|
|
412
|
+
return 0
|
package/finops/integrate.py
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
claude-finops --install-statusline wire it into settings
|
|
5
5
|
claude-finops --hook retired no-op, kept so existing installs do not error
|
|
6
6
|
claude-finops --install-hook prints that the hook is retired and does nothing else
|
|
7
|
+
claude-finops --install-guard wire the session guard (finops/guard.py) into settings
|
|
7
8
|
|
|
8
9
|
The prompt hook that used to "suggest a cheaper model" is retired: that
|
|
9
10
|
suggestion had no basis — it would have meant repricing work that never ran.
|
|
@@ -89,6 +90,19 @@ def _command():
|
|
|
89
90
|
return f"{shlex.quote(sys.executable)} {shlex.quote(os.path.join(root, 'run.py'))}"
|
|
90
91
|
|
|
91
92
|
|
|
93
|
+
def _guard_command():
|
|
94
|
+
"""The hook command: this installation's own code, and never a blocking exit.
|
|
95
|
+
|
|
96
|
+
Not the claude-finops on PATH: that can be an older release without --guard,
|
|
97
|
+
and its "unknown option" exit code 2 is what Claude Code reads as "block this
|
|
98
|
+
tool call". `|| true` keeps any failure (missing Python, a moved install) from
|
|
99
|
+
ever blocking; a successful run's JSON on stdout passes through unchanged.
|
|
100
|
+
"""
|
|
101
|
+
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
102
|
+
run = f"{shlex.quote(sys.executable)} {shlex.quote(os.path.join(root, 'run.py'))}"
|
|
103
|
+
return f"{run} --guard || true"
|
|
104
|
+
|
|
105
|
+
|
|
92
106
|
def _load_settings():
|
|
93
107
|
try:
|
|
94
108
|
with open(SETTINGS) as fh:
|
|
@@ -146,3 +160,67 @@ def install_statusline(remove=False):
|
|
|
146
160
|
return {"ok": True, "message": f"Installed the statusline in {SETTINGS}. It shows the model, "
|
|
147
161
|
"context % and 5-hour limit % under every prompt. "
|
|
148
162
|
"Undo: claude-finops --uninstall-statusline"}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------- guard ----
|
|
166
|
+
|
|
167
|
+
def _is_guard(h):
|
|
168
|
+
cmd = h.get("command") if isinstance(h, dict) else None
|
|
169
|
+
return isinstance(cmd, str) and "--guard" in cmd.split() and "finops" in cmd
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def guard_state():
|
|
173
|
+
""""installed" when our PreToolUse guard hook is in settings, else None."""
|
|
174
|
+
groups = ((_load_settings().get("hooks") or {}).get("PreToolUse")) or []
|
|
175
|
+
for g in groups if isinstance(groups, list) else []:
|
|
176
|
+
if isinstance(g, dict) and any(_is_guard(h) for h in g.get("hooks") or []):
|
|
177
|
+
return "installed"
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def install_guard(remove=False):
|
|
182
|
+
"""Add (or remove) our PreToolUse hook. Returns {"ok", "message"}.
|
|
183
|
+
|
|
184
|
+
Only our own entry is ever touched: other PreToolUse hooks, and other hook
|
|
185
|
+
events, are left exactly as they were.
|
|
186
|
+
"""
|
|
187
|
+
s = _load_settings()
|
|
188
|
+
hooks = s.get("hooks") if isinstance(s.get("hooks"), dict) else {}
|
|
189
|
+
groups = hooks.get("PreToolUse") if isinstance(hooks.get("PreToolUse"), list) else []
|
|
190
|
+
if remove:
|
|
191
|
+
if guard_state() is None:
|
|
192
|
+
return {"ok": True, "message": "The session guard is not installed; nothing changed."}
|
|
193
|
+
kept = []
|
|
194
|
+
for g in groups:
|
|
195
|
+
if isinstance(g, dict) and isinstance(g.get("hooks"), list):
|
|
196
|
+
g = dict(g, hooks=[h for h in g["hooks"] if not _is_guard(h)])
|
|
197
|
+
if not g["hooks"]:
|
|
198
|
+
continue
|
|
199
|
+
kept.append(g)
|
|
200
|
+
if kept:
|
|
201
|
+
hooks["PreToolUse"] = kept
|
|
202
|
+
else:
|
|
203
|
+
hooks.pop("PreToolUse", None)
|
|
204
|
+
if hooks:
|
|
205
|
+
s["hooks"] = hooks
|
|
206
|
+
else:
|
|
207
|
+
s.pop("hooks", None)
|
|
208
|
+
_save_settings(s)
|
|
209
|
+
return {"ok": True, "message": f"Removed the session guard from {SETTINGS}"}
|
|
210
|
+
if guard_state() == "installed":
|
|
211
|
+
return {"ok": True, "message": "The session guard is already installed."}
|
|
212
|
+
groups.append({"matcher": "", "hooks": [
|
|
213
|
+
{"type": "command", "command": _guard_command(), "timeout": 10}]})
|
|
214
|
+
hooks["PreToolUse"] = groups
|
|
215
|
+
s["hooks"] = hooks
|
|
216
|
+
_save_settings(s)
|
|
217
|
+
msg = (f"Installed the session guard in {SETTINGS}. It applies to new Claude Code sessions. "
|
|
218
|
+
"Undo: claude-finops --uninstall-guard")
|
|
219
|
+
try:
|
|
220
|
+
from .guard import load_guard_settings, has_any_budget
|
|
221
|
+
if not has_any_budget(load_guard_settings()):
|
|
222
|
+
msg += (" It stays inactive until you set a per-session token budget "
|
|
223
|
+
"(Budgets page, or guard.session_tokens in settings.local.json).")
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
return {"ok": True, "message": msg}
|
package/finops/jev.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Jev (TypeSafe's "System One" model): install its Claude Code plugin, keep its API key.
|
|
2
|
+
|
|
3
|
+
Jev makes fast, typed decisions (pick one, yes or no, a score, a value). It does not write
|
|
4
|
+
text or code, so it cannot stand in for the model behind Claude Code; the plugin teaches
|
|
5
|
+
Claude how to use Jev in the software you build. https://docs.typesafe.ai
|
|
6
|
+
|
|
7
|
+
install claude plugin marketplace add typesafe-ai/skills
|
|
8
|
+
claude plugin install typesafe@typesafe-ai
|
|
9
|
+
uninstall claude plugin uninstall typesafe@typesafe-ai
|
|
10
|
+
API key env.TYPESAFE_API_KEY in ~/.claude/settings.json, which Claude Code hands to
|
|
11
|
+
every new session (the page never gets the key back, only its last four)
|
|
12
|
+
|
|
13
|
+
Every command is a fixed argument list, run only when the user clicks.
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
|
|
21
|
+
from . import integrate
|
|
22
|
+
|
|
23
|
+
PLUGIN = "typesafe@typesafe-ai"
|
|
24
|
+
MARKETPLACE = "typesafe-ai/skills"
|
|
25
|
+
ENV_KEY = "TYPESAFE_API_KEY"
|
|
26
|
+
JEV_USD_PER_INPUT_TOKEN = 0.042 / 1e6 # TypeSafe's published price; output is free
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ------------------------------------------------------------------ plugin ----
|
|
30
|
+
|
|
31
|
+
def _claude():
|
|
32
|
+
return shutil.which("claude")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run(cmd, log):
|
|
36
|
+
"""Run one fixed command, log every output line, and put its last lines in any error
|
|
37
|
+
(so "already added" can be recognised). No shell."""
|
|
38
|
+
try:
|
|
39
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
40
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
41
|
+
raise RuntimeError(f"{' '.join(cmd[1:4])} could not run: {e}") from None
|
|
42
|
+
lines = [ln.strip() for ln in (r.stdout + "\n" + r.stderr).splitlines() if ln.strip()]
|
|
43
|
+
for ln in lines:
|
|
44
|
+
log(ln[-200:])
|
|
45
|
+
if r.returncode:
|
|
46
|
+
raise RuntimeError(f"{' '.join(cmd[1:4])} failed: " + (" ".join(lines[-3:]) or f"exit {r.returncode}"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def status():
|
|
50
|
+
"""Is the plugin installed? Never raises: a broken `claude` just reads as not installed."""
|
|
51
|
+
exe = _claude()
|
|
52
|
+
out = {"claude": bool(exe), "installed": False, "version": None, "enabled": None}
|
|
53
|
+
if not exe:
|
|
54
|
+
return out
|
|
55
|
+
try:
|
|
56
|
+
r = subprocess.run([exe, "plugin", "list", "--json"], capture_output=True, text=True,
|
|
57
|
+
timeout=20)
|
|
58
|
+
rows = json.loads(r.stdout or "[]")
|
|
59
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
60
|
+
return out
|
|
61
|
+
for p in rows if isinstance(rows, list) else []:
|
|
62
|
+
if isinstance(p, dict) and p.get("id") == PLUGIN:
|
|
63
|
+
out.update(installed=True, version=p.get("version"), enabled=p.get("enabled"))
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _need_claude():
|
|
68
|
+
exe = _claude()
|
|
69
|
+
if not exe:
|
|
70
|
+
raise RuntimeError("Claude Code's `claude` command was not found. Install Claude Code, "
|
|
71
|
+
"or start the dashboard from a terminal where `claude` works.")
|
|
72
|
+
return exe
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def install(log):
|
|
76
|
+
exe = _need_claude()
|
|
77
|
+
log(f"Adding TypeSafe's plugin list: claude plugin marketplace add {MARKETPLACE}")
|
|
78
|
+
try:
|
|
79
|
+
_run([exe, "plugin", "marketplace", "add", MARKETPLACE], log)
|
|
80
|
+
except RuntimeError as e:
|
|
81
|
+
if "already" not in str(e).lower():
|
|
82
|
+
raise
|
|
83
|
+
log("Already added. Carrying on.")
|
|
84
|
+
log(f"Installing the plugin: claude plugin install {PLUGIN}")
|
|
85
|
+
_run([exe, "plugin", "install", PLUGIN], log)
|
|
86
|
+
log("Installed. New Claude Code sessions can use it; restart any open ones.")
|
|
87
|
+
return status()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def uninstall(log):
|
|
91
|
+
exe = _need_claude()
|
|
92
|
+
log(f"Removing the plugin: claude plugin uninstall {PLUGIN}")
|
|
93
|
+
_run([exe, "plugin", "uninstall", PLUGIN], log)
|
|
94
|
+
log("Removed.")
|
|
95
|
+
return status()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --------------------------------------------------------------------- key ----
|
|
99
|
+
|
|
100
|
+
def key_status():
|
|
101
|
+
"""Where the key comes from and its last four characters. Never the key."""
|
|
102
|
+
shell = os.environ.get(ENV_KEY)
|
|
103
|
+
stored = ((integrate._load_settings().get("env") or {}).get(ENV_KEY)) or ""
|
|
104
|
+
key = shell or stored
|
|
105
|
+
return {"source": "shell" if shell else "claude_settings" if stored else None,
|
|
106
|
+
"last4": key[-4:] if len(key) >= 12 else None}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save_key(value):
|
|
110
|
+
value = (value or "").strip()
|
|
111
|
+
if not value:
|
|
112
|
+
raise ValueError("Paste your Jev API key first.")
|
|
113
|
+
if any(ch.isspace() for ch in value):
|
|
114
|
+
raise ValueError("A key has no spaces or line breaks in it.")
|
|
115
|
+
if len(value) < 16:
|
|
116
|
+
raise ValueError("That looks too short for a TypeSafe API key.")
|
|
117
|
+
s = integrate._load_settings()
|
|
118
|
+
env = s.get("env") if isinstance(s.get("env"), dict) else {}
|
|
119
|
+
env[ENV_KEY] = value
|
|
120
|
+
s["env"] = env
|
|
121
|
+
integrate._save_settings(s)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def remove_key():
|
|
125
|
+
s = integrate._load_settings()
|
|
126
|
+
env = s.get("env") if isinstance(s.get("env"), dict) else {}
|
|
127
|
+
if ENV_KEY not in env:
|
|
128
|
+
return
|
|
129
|
+
env.pop(ENV_KEY)
|
|
130
|
+
if env:
|
|
131
|
+
s["env"] = env
|
|
132
|
+
else:
|
|
133
|
+
s.pop("env", None)
|
|
134
|
+
integrate._save_settings(s)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --------------------------------------------------------------- where it fits ----
|
|
138
|
+
# A prompt Jev could have answered: short (at most two model calls), no tools, a short
|
|
139
|
+
# reply, and worded as a decision rather than a request to build something.
|
|
140
|
+
DECIDE = re.compile(r"\b(classify|categori[sz]e|category|label|which (one|of)|yes or no|true or false|"
|
|
141
|
+
r"is (this|it) (a|an)\b|extract|score|rate this|triage|route|detect|decide|pick one|"
|
|
142
|
+
r"choose|prioriti[sz]e|sentiment|is it valid|should (i|we)\b|does (this|it)\b)", re.I)
|
|
143
|
+
BUILD = re.compile(r"\b(write|implement|build|create|fix|refactor|add|update|change|make|generate|"
|
|
144
|
+
r"design|explain|debug)\b", re.I)
|
|
145
|
+
# Claude Code's own summary after a context compaction is stored like a prompt; it is not yours.
|
|
146
|
+
NOT_YOURS = ("This session is being continued from a previous conversation",)
|
|
147
|
+
_MASK = re.compile(r"\S+@\S+|(?:~|\.{0,2})?/[\w.\-/]+|\d{3,}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _mask(text):
|
|
151
|
+
return _MASK.sub("…", re.sub(r"\s+", " ", text or "")).strip()[:140]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def fit(a, f=None):
|
|
155
|
+
"""How much of the filtered Claude Code work was decision-shaped, and what it would cost on Jev."""
|
|
156
|
+
w, p = a.where(f)
|
|
157
|
+
total = a.one(f"SELECT COALESCE(SUM(r.est_cost_usd),0) c FROM requests r "
|
|
158
|
+
f"WHERE {w} AND r.agent='claude'", p)["c"]
|
|
159
|
+
rows = a.q(f"""SELECT pr.text, pr.request_count, pr.output_tokens, pr.est_cost_usd cost,
|
|
160
|
+
COALESCE(pr.input_tokens,0) + COALESCE(pr.cache_read_tokens,0)
|
|
161
|
+
+ COALESCE(pr.cache_write_tokens,0) ctx
|
|
162
|
+
FROM prompts pr WHERE pr.agent='claude' AND pr.id IN (
|
|
163
|
+
SELECT r.prompt_id FROM requests r WHERE {w} AND r.agent='claude')
|
|
164
|
+
AND COALESCE(pr.request_count,0) <= 2 AND COALESCE(pr.tool_calls,0) = 0
|
|
165
|
+
AND COALESCE(pr.output_tokens,0) <= 300""", p)
|
|
166
|
+
hits = [r for r in rows if DECIDE.search(r["text"] or "") and not BUILD.search((r["text"] or "")[:200])
|
|
167
|
+
and not (r["text"] or "").lstrip().startswith(NOT_YOURS)]
|
|
168
|
+
n_all = a.one(f"SELECT COUNT(DISTINCT r.prompt_id) n FROM requests r "
|
|
169
|
+
f"WHERE {w} AND r.agent='claude' AND r.prompt_id IS NOT NULL", p)["n"]
|
|
170
|
+
cost = sum(r["cost"] or 0 for r in hits)
|
|
171
|
+
return {"prompts": len(hits), "total_prompts": n_all, "claude_cost": cost,
|
|
172
|
+
"jev_cost": sum(r["ctx"] or 0 for r in hits) * JEV_USD_PER_INPUT_TOKEN,
|
|
173
|
+
"share_pct": 100.0 * cost / total if total else 0.0, "total_cost": total,
|
|
174
|
+
"examples": [{"text": _mask(r["text"]), "cost": r["cost"] or 0}
|
|
175
|
+
for r in sorted(hits, key=lambda r: -(r["cost"] or 0))[:5]]}
|