claude-finops 0.13.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 +130 -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 +830 -59
- package/web/guide/budgets.png +0 -0
- package/web/styles.css +71 -0
package/README.md
CHANGED
|
@@ -204,14 +204,61 @@ billed at another model's rate.
|
|
|
204
204
|
},
|
|
205
205
|
"budgets": { "monthly_usd": null, "daily_usd": null, "per_project_usd": {} },
|
|
206
206
|
"alert_thresholds_pct": [50, 75, 90, 100],
|
|
207
|
+
"guard": { // per-session token budget + the session guard hook
|
|
208
|
+
"session_tokens": null, // billable tokens per Claude Code session; null => off
|
|
209
|
+
"warn_pct": [75, 80], // warn (never block) as a session crosses these
|
|
210
|
+
"after_approval": "step", // "step": ask again every step_pct; "once": never again
|
|
211
|
+
"step_pct": 25,
|
|
212
|
+
"projects": {} // {"/abs/path": {"session_tokens": n} | {"off": true}}
|
|
213
|
+
},
|
|
207
214
|
"waste_rules": { /* thresholds for each detector */ },
|
|
208
215
|
"anomaly": { /* z-score and ratio triggers */ },
|
|
209
216
|
"scorecard": { /* the reference points each dimension is graded against */ }
|
|
210
217
|
}
|
|
211
218
|
```
|
|
212
219
|
|
|
213
|
-
The **Budgets**
|
|
214
|
-
|
|
220
|
+
The **Budgets** page has three numbered blocks (① money, ② tokens, ③ one conversation) and an
|
|
221
|
+
Advanced section, folded away, for plan limits and alert thresholds. **How does this work?** (and
|
|
222
|
+
the small **?** on each block) opens a short picture guide; it also opens by itself the first
|
|
223
|
+
time. Values are saved to `~/.claude-finops/settings.local.json`. Each amount suggests values
|
|
224
|
+
from your own last 30 days, accepts shorthand (`20M`, `500k`, `$3,000`), and explains a bad
|
|
225
|
+
value before saving.
|
|
226
|
+
|
|
227
|
+
**Live warnings** (inside block ③) is the optional session guard hook. Your budgets work
|
|
228
|
+
without it; it only adds the warning and "continue?" prompt inside Claude Code.
|
|
229
|
+
|
|
230
|
+
**Settings** (sidebar, Setup) stores the optional API keys used by **Billed vs local**, in
|
|
231
|
+
`~/.claude-finops/secrets.local.json` (0600, the same file `--set-key` writes). The page only
|
|
232
|
+
ever gets back whether a key is set, where from, and its last four characters. An environment
|
|
233
|
+
variable (`ANTHROPIC_ADMIN_KEY`, `CURSOR_API_KEY`) takes priority over a stored key.
|
|
234
|
+
|
|
235
|
+
### Per-session token budget and the session guard
|
|
236
|
+
|
|
237
|
+
`guard.session_tokens` is a budget for one Claude Code session, in billable tokens (input +
|
|
238
|
+
output + cache read + cache write, the same figure as the Tokens column in Sessions). Cache
|
|
239
|
+
reads dominate long sessions, which is exactly what a session cap is for. A project override
|
|
240
|
+
(matched on the project path; the most specific path wins) sets a different budget or turns
|
|
241
|
+
it off for that project.
|
|
242
|
+
|
|
243
|
+
With a budget set, **Budgets** shows the largest session this billing period against its
|
|
244
|
+
budget and how many sessions went over, and the dots in **Sessions** mark sessions at 75%
|
|
245
|
+
(amber) and 100% (red) of theirs.
|
|
246
|
+
|
|
247
|
+
The **session guard** is an opt-in Claude Code `PreToolUse` hook (`--install-guard`, or
|
|
248
|
+
**Install** under Live warnings on the Budgets page, block ③). Before each tool call it counts the session's tokens
|
|
249
|
+
from its transcript (only the lines added since the last call), and:
|
|
250
|
+
|
|
251
|
+
- below the budget, warns you once at each `warn_pct` and suggests `/compact` to Claude;
|
|
252
|
+
- at 100%, asks you to approve the next tool call; after you approve, it asks again every
|
|
253
|
+
`step_pct` (`"step"`) or not at all for that session (`"once"`). If you decline, it asks
|
|
254
|
+
again on the next tool call.
|
|
255
|
+
|
|
256
|
+
Checked on Claude Code 2.1.284: the approval prompt appears in the default mode, in
|
|
257
|
+
`--dangerously-skip-permissions` (bypass) mode, and for tools on your allow list. In
|
|
258
|
+
non-interactive `claude -p` there is nobody to ask, so the tool call is refused. A hook cannot
|
|
259
|
+
end a session, and the guard fails open: if it errors or cannot read the transcript, the tool
|
|
260
|
+
call goes ahead. It keeps a small state file per session in `~/.claude-finops/data/guard/`
|
|
261
|
+
(deleted after 30 days).
|
|
215
262
|
|
|
216
263
|
Note on `waste_rules.low_output_ratio_vs_median`: sessions are flagged relative to
|
|
217
264
|
*your own* median output ratio rather than an absolute number, because a healthy ratio
|
|
@@ -277,6 +324,8 @@ claude-finops --set-key store a provider API key (hidden prompt, 0600)
|
|
|
277
324
|
claude-finops --keys which provider keys are configured
|
|
278
325
|
claude-finops --install-statusline model, context % and 5-hour limit under every prompt
|
|
279
326
|
claude-finops --uninstall-statusline remove it again
|
|
327
|
+
claude-finops --install-guard warn near, and ask at, your per-session token budget
|
|
328
|
+
claude-finops --uninstall-guard remove it again
|
|
280
329
|
claude-finops --help everything
|
|
281
330
|
```
|
|
282
331
|
|
|
@@ -328,7 +377,8 @@ The app makes exactly one outbound request of its own: once a day it asks
|
|
|
328
377
|
you when an upgrade is out (npm has no way to push one at you). It sends nothing
|
|
329
378
|
about you or your usage. Turn it off with `NO_UPDATE_NOTIFIER=1` or
|
|
330
379
|
`CLAUDE_FINOPS_NO_UPDATE_CHECK=1`. Provider cost APIs are called only if you
|
|
331
|
-
configure a key with `--set-key
|
|
380
|
+
configure a key (with `--set-key` or on the Settings page), and only
|
|
381
|
+
when you click Refresh on Billed vs local.
|
|
332
382
|
|
|
333
383
|
---
|
|
334
384
|
|
|
@@ -390,6 +440,30 @@ files on Windows.
|
|
|
390
440
|
- **Install statusline** (Act now, or `--install-statusline`) adds a `statusLine` entry to
|
|
391
441
|
`~/.claude/settings.json`, keeping a `.finops-backup` copy. It won't replace a statusline you
|
|
392
442
|
already have.
|
|
443
|
+
- **Install** under Live warnings (Budgets, block ③, or `--install-guard`) adds one `PreToolUse` hook entry to
|
|
444
|
+
`~/.claude/settings.json`, keeping a `.finops-backup` copy. Your other hooks are left
|
|
445
|
+
alone, and uninstalling removes only that entry.
|
|
446
|
+
|
|
447
|
+
---
|
|
448
|
+
|
|
449
|
+
## Jev (fast decisions)
|
|
450
|
+
|
|
451
|
+
[Jev](https://docs.typesafe.ai) is TypeSafe AI's "System One" model: very fast, very cheap typed
|
|
452
|
+
decisions (pick one, yes or no, a score, a value). It does not write text or code, so it does not
|
|
453
|
+
make Claude Code itself cheaper; it makes the apps and scripts you build cheaper where an LLM call
|
|
454
|
+
only decides something.
|
|
455
|
+
|
|
456
|
+
The **Jev (fast decisions)** page (sidebar, Optimize):
|
|
457
|
+
|
|
458
|
+
- **Install** runs Claude Code's own plugin manager, after showing you the commands:
|
|
459
|
+
`claude plugin marketplace add typesafe-ai/skills` then `claude plugin install typesafe@typesafe-ai`.
|
|
460
|
+
**Uninstall** runs `claude plugin uninstall typesafe@typesafe-ai`.
|
|
461
|
+
- **API key** is saved as `env.TYPESAFE_API_KEY` in `~/.claude/settings.json` (backed up first,
|
|
462
|
+
nothing else changed), so new Claude Code sessions can use it. The page only ever gets back
|
|
463
|
+
its last four characters. A key exported in your shell takes priority.
|
|
464
|
+
- **Where Jev fits** counts your Claude Code prompts that were really just a decision (short, no
|
|
465
|
+
tools, worded as "which one", "yes or no", "classify"…) and what they would cost at TypeSafe's
|
|
466
|
+
published price ($0.042 per million input tokens). Read-only.
|
|
393
467
|
|
|
394
468
|
---
|
|
395
469
|
|
package/config/settings.json
CHANGED
|
@@ -30,6 +30,15 @@
|
|
|
30
30
|
90,
|
|
31
31
|
100
|
|
32
32
|
],
|
|
33
|
+
"guard": {
|
|
34
|
+
"_comment": "Per-session token budget (billable tokens) and the opt-in session guard hook (claude-finops --install-guard). session_tokens null => no per-session budget and the guard allows everything. projects maps an absolute project path to {\"session_tokens\": n} or {\"off\": true}; sessions maps one Claude Code session id to the same (set from Running sessions, forgotten after 30 days).",
|
|
35
|
+
"session_tokens": null,
|
|
36
|
+
"warn_pct": [75, 80],
|
|
37
|
+
"after_approval": "step",
|
|
38
|
+
"step_pct": 25,
|
|
39
|
+
"projects": {},
|
|
40
|
+
"sessions": {}
|
|
41
|
+
},
|
|
33
42
|
"waste_rules": {
|
|
34
43
|
"long_prompt_chars": 6000,
|
|
35
44
|
"large_context_tokens": 150000,
|
package/finops/analytics.py
CHANGED
|
@@ -124,6 +124,9 @@ def _validate_settings(cur, defaults):
|
|
|
124
124
|
if pct is not None:
|
|
125
125
|
bad.append("alert_thresholds_pct")
|
|
126
126
|
cur["alert_thresholds_pct"] = defaults.get("alert_thresholds_pct", [])
|
|
127
|
+
from .guard import validate_guard
|
|
128
|
+
cur["guard"], guard_bad = validate_guard(cur.get("guard"), defaults.get("guard"))
|
|
129
|
+
bad += guard_bad
|
|
127
130
|
for path in bad:
|
|
128
131
|
print(f"finops: settings.local.json has an invalid '{path}'; using the shipped default",
|
|
129
132
|
file=sys.stderr)
|
|
@@ -571,7 +574,8 @@ class Analytics:
|
|
|
571
574
|
rows = self.q(f"""
|
|
572
575
|
SELECT s.id session_id, s.title, s.git_branch, s.cli_version, s.started_at,
|
|
573
576
|
s.ended_at, s.duration_s, s.files_touched, pr.name project, pr.id project_id,
|
|
574
|
-
s.agent, s.source_file,
|
|
577
|
+
s.agent, s.source_file, pr.path project_path,
|
|
578
|
+
s.billable_tokens session_tokens,
|
|
575
579
|
COUNT(DISTINCT r.prompt_id) prompts, COUNT(*) requests,
|
|
576
580
|
SUM(r.tool_call_count) tool_calls,
|
|
577
581
|
SUM(r.input_tokens) input_tokens, SUM(r.output_tokens) output_tokens,
|
|
@@ -590,9 +594,21 @@ class Analytics:
|
|
|
590
594
|
r["output_ratio"] = (r["output_tokens"] or 0) / r["tokens"] if r["tokens"] else 0
|
|
591
595
|
cr, cw = r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0
|
|
592
596
|
r["cache_hit_ratio"] = (cr / (cr + cw)) if (cr + cw) else None
|
|
597
|
+
r["budget_tokens"] = self.session_budget(r["agent"], r.pop("project_path"), r["session_id"])
|
|
593
598
|
r["resume"] = resume_command(r["session_id"], r.pop("agent"), r.pop("source_file"))
|
|
594
599
|
return rows
|
|
595
600
|
|
|
601
|
+
def session_budget(self, agent, project_path, session_id=None):
|
|
602
|
+
"""The per-session token budget that applies to a session, or None.
|
|
603
|
+
|
|
604
|
+
Claude Code sessions only: the guard hook runs there, and other agents'
|
|
605
|
+
sessions are not what the budget was set for.
|
|
606
|
+
"""
|
|
607
|
+
if agent != "claude":
|
|
608
|
+
return None
|
|
609
|
+
from .guard import budget_for
|
|
610
|
+
return budget_for(self.settings.get("guard") or {}, project_path, session_id)
|
|
611
|
+
|
|
596
612
|
def sessions_total(self, f=None):
|
|
597
613
|
w, p = self.where(f)
|
|
598
614
|
return self.one(f"SELECT COUNT(DISTINCT s.id) n FROM requests r "
|
|
@@ -1575,11 +1591,18 @@ class Analytics:
|
|
|
1575
1591
|
"thresholds_breached": breached, "thresholds_forecast_breach": fbreach,
|
|
1576
1592
|
"unit": unit}
|
|
1577
1593
|
|
|
1594
|
+
out["suggest"] = self.budget_suggestions(f)
|
|
1578
1595
|
fc_cost = fc.get("scenarios", {}).get("expected", {}).get("end_of_period_cost")
|
|
1579
1596
|
out["lines"].append(line("Monthly spend", b.get("monthly_usd"), period["c"], fc_cost))
|
|
1580
1597
|
out["lines"].append(line("Daily spend", b.get("daily_usd"), today["c"], today["c"]))
|
|
1581
1598
|
out["lines"].append(line("Monthly tokens", b.get("monthly_tokens"), period["t"],
|
|
1582
1599
|
fc.get("end_of_period_tokens"), unit="tokens"))
|
|
1600
|
+
g = self.settings.get("guard") or {}
|
|
1601
|
+
from .guard import has_any_budget
|
|
1602
|
+
if has_any_budget(g):
|
|
1603
|
+
out["lines"].append(self._session_budget_line(f, bp, line))
|
|
1604
|
+
else:
|
|
1605
|
+
out["lines"].append(line("Per-session tokens", None, 0, None, unit="tokens"))
|
|
1583
1606
|
for proj, bud in (b.get("per_project_usd") or {}).items():
|
|
1584
1607
|
act = self.one("""SELECT COALESCE(SUM(r.est_cost_usd),0) c FROM requests r
|
|
1585
1608
|
JOIN projects pr ON pr.id=r.project_id
|
|
@@ -1593,6 +1616,59 @@ class Analytics:
|
|
|
1593
1616
|
out["lines"].append(line(f"Model: {self.pricing.display_name(model)}", bud, act, None))
|
|
1594
1617
|
return out
|
|
1595
1618
|
|
|
1619
|
+
def budget_suggestions(self, f=None):
|
|
1620
|
+
"""Figures the Budgets form offers as suggestions: recent spend and session sizes.
|
|
1621
|
+
|
|
1622
|
+
Last 30 days up to today, under the current filter; session sizes are Claude Code
|
|
1623
|
+
sessions' whole-session billable tokens (a sorted sample, at most 2,000 values),
|
|
1624
|
+
so the page can say how many past sessions a budget would have caught.
|
|
1625
|
+
"""
|
|
1626
|
+
w, p = self.where(f)
|
|
1627
|
+
start = (self.today() - timedelta(days=29)).isoformat()
|
|
1628
|
+
days = [r["c"] for r in self.q(
|
|
1629
|
+
f"SELECT r.day, SUM(r.est_cost_usd) c FROM requests r WHERE {w} AND r.day >= ? "
|
|
1630
|
+
f"GROUP BY r.day", p + [start])]
|
|
1631
|
+
tot = self.one(f"SELECT COALESCE(SUM(r.est_cost_usd),0) c, COALESCE(SUM(r.billable_tokens),0) t "
|
|
1632
|
+
f"FROM requests r WHERE {w} AND r.day >= ?", p + [start])
|
|
1633
|
+
sizes = [r["t"] for r in self.q(
|
|
1634
|
+
"SELECT billable_tokens t FROM sessions WHERE agent='claude' AND billable_tokens > 0 "
|
|
1635
|
+
"ORDER BY t")]
|
|
1636
|
+
if len(sizes) > 2000:
|
|
1637
|
+
step = len(sizes) / 2000.0
|
|
1638
|
+
sizes = [sizes[int(i * step)] for i in range(2000)]
|
|
1639
|
+
days.sort()
|
|
1640
|
+
return {"spend_30d": round(tot["c"], 2), "tokens_30d": tot["t"],
|
|
1641
|
+
"daily_avg": round(tot["c"] / 30.0, 2),
|
|
1642
|
+
"daily_p90": round(days[min(len(days) - 1, int(len(days) * 0.9))], 2) if days else 0,
|
|
1643
|
+
"session_tokens": sizes}
|
|
1644
|
+
|
|
1645
|
+
def _session_budget_line(self, f, bp, line):
|
|
1646
|
+
"""Largest Claude Code session this period against its own budget, and who went over."""
|
|
1647
|
+
w, p = self.where(f)
|
|
1648
|
+
rows = self.q(f"""SELECT s.id session_id, s.title, pr.name project, pr.path path,
|
|
1649
|
+
s.billable_tokens tokens
|
|
1650
|
+
FROM sessions s JOIN projects pr ON pr.id = s.project_id
|
|
1651
|
+
WHERE s.agent = 'claude' AND s.id IN (
|
|
1652
|
+
SELECT r.session_id FROM requests r
|
|
1653
|
+
WHERE {w} AND r.day >= ? AND r.day <= ?)""",
|
|
1654
|
+
p + [bp["start"], bp["end"]])
|
|
1655
|
+
scored = []
|
|
1656
|
+
for r in rows:
|
|
1657
|
+
bud = self.session_budget("claude", r.pop("path"), r["session_id"])
|
|
1658
|
+
if bud:
|
|
1659
|
+
r["budget"], r["pct"] = bud, round(100.0 * (r["tokens"] or 0) / bud, 1)
|
|
1660
|
+
scored.append(r)
|
|
1661
|
+
scored.sort(key=lambda r: r["pct"], reverse=True)
|
|
1662
|
+
top = scored[0] if scored else None
|
|
1663
|
+
g = self.settings.get("guard") or {}
|
|
1664
|
+
ln = line("Per-session tokens", top["budget"] if top else g.get("session_tokens"),
|
|
1665
|
+
top["tokens"] if top else 0, None, unit="tokens")
|
|
1666
|
+
over = [r for r in scored if r["pct"] >= 100]
|
|
1667
|
+
ln["sessions_over"] = len(over)
|
|
1668
|
+
ln["top_over"] = over[:5]
|
|
1669
|
+
ln["basis"] = "largest session this period vs its budget"
|
|
1670
|
+
return ln
|
|
1671
|
+
|
|
1596
1672
|
# ---------------- anomalies ----------------
|
|
1597
1673
|
def anomalies(self, f=None):
|
|
1598
1674
|
w, p = self.where(f)
|
|
@@ -1891,7 +1967,7 @@ class Analytics:
|
|
|
1891
1967
|
return {
|
|
1892
1968
|
"agents": self.agents(),
|
|
1893
1969
|
"models": self.q("SELECT model, agent, COUNT(*) n FROM requests GROUP BY 1 ORDER BY 3 DESC"),
|
|
1894
|
-
"projects": self.q("SELECT pr.id project_id, pr.name, pr.is_sandbox, pr.agent, COUNT(r.id) n"
|
|
1970
|
+
"projects": self.q("SELECT pr.id project_id, pr.name, pr.path, pr.is_sandbox, pr.agent, COUNT(r.id) n"
|
|
1895
1971
|
" FROM projects pr LEFT JOIN requests r ON r.project_id=pr.id"
|
|
1896
1972
|
" GROUP BY pr.id HAVING n>0 ORDER BY n DESC"),
|
|
1897
1973
|
"categories": self.q("SELECT category, COUNT(*) n FROM prompts GROUP BY 1 ORDER BY 2 DESC"),
|
package/finops/api.py
CHANGED
|
@@ -38,7 +38,7 @@ def _int(qs, key, default, lo=0, hi=1_000_000):
|
|
|
38
38
|
|
|
39
39
|
SETTINGS_SHAPE = {
|
|
40
40
|
"budgets": dict, "limits": dict, "alert_thresholds_pct": list, "waste_rules": dict,
|
|
41
|
-
"anomaly": dict, "scorecard": dict, "account": dict, "billing_period": dict,
|
|
41
|
+
"anomaly": dict, "scorecard": dict, "account": dict, "billing_period": dict, "guard": dict,
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
|
|
@@ -62,6 +62,89 @@ def _validate_thresholds(v):
|
|
|
62
62
|
if not all(_is_num(x) and 0 <= x <= 1000 for x in v):
|
|
63
63
|
raise BadRequest("alert_thresholds_pct must be a list of numbers 0..1000")
|
|
64
64
|
|
|
65
|
+
def _validate_guard(v):
|
|
66
|
+
from .guard import validate_guard
|
|
67
|
+
_, bad = validate_guard(v, {})
|
|
68
|
+
if bad:
|
|
69
|
+
raise BadRequest(f"invalid {', '.join(bad)}: session_tokens > 0 or null, warn_pct "
|
|
70
|
+
"numbers between 0 and 100, after_approval step|once, step_pct 1..1000, "
|
|
71
|
+
"projects {path: {session_tokens: n} | {off: true}}")
|
|
72
|
+
|
|
73
|
+
class NotFound(BadRequest):
|
|
74
|
+
"""A 404 raised from a helper; handled next to BadRequest."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _session_transcript(sid):
|
|
78
|
+
from . import guard as G
|
|
79
|
+
from .etl import DEFAULT_SOURCE
|
|
80
|
+
if not G.SAFE_ID.match(sid or ""):
|
|
81
|
+
raise BadRequest("That is not a Claude Code session id.")
|
|
82
|
+
tp = G.find_transcript(sid, [os.environ.get("CLAUDE_PROJECTS") or DEFAULT_SOURCE])
|
|
83
|
+
if not tp:
|
|
84
|
+
raise NotFound("No Claude Code transcript found for that session.")
|
|
85
|
+
return tp
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _session_limit_status(a, sid):
|
|
89
|
+
"""GET /api/guard/session/<id>: tokens used so far (as the guard counts them),
|
|
90
|
+
this session's own limit, the budget that applies, and whether the hook is installed."""
|
|
91
|
+
from . import guard as G
|
|
92
|
+
from .integrate import guard_state
|
|
93
|
+
tp = _session_transcript(sid)
|
|
94
|
+
cwd = None
|
|
95
|
+
with open(tp, errors="replace") as fh:
|
|
96
|
+
for i, line in enumerate(fh):
|
|
97
|
+
if '"cwd"' in line:
|
|
98
|
+
try:
|
|
99
|
+
cwd = json.loads(line).get("cwd")
|
|
100
|
+
except ValueError:
|
|
101
|
+
pass
|
|
102
|
+
if cwd or i > 50:
|
|
103
|
+
break
|
|
104
|
+
cfg = a.settings.get("guard") or {}
|
|
105
|
+
try:
|
|
106
|
+
installed = guard_state() == "installed"
|
|
107
|
+
except Exception:
|
|
108
|
+
installed = False
|
|
109
|
+
return {"session_id": sid, "tokens": G.session_usage(tp, sid),
|
|
110
|
+
"limit": (cfg.get("sessions") or {}).get(sid), "applies": G.budget_for(cfg, cwd, sid),
|
|
111
|
+
"installed": installed}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _set_session_limit(sid, payload):
|
|
115
|
+
"""POST /api/do/session_limit/<id>: {session_tokens: n} | {off: true} | {remove: true}."""
|
|
116
|
+
global A
|
|
117
|
+
from . import guard as G
|
|
118
|
+
import time
|
|
119
|
+
if not G.SAFE_ID.match(sid or ""):
|
|
120
|
+
raise BadRequest("That is not a Claude Code session id.")
|
|
121
|
+
if payload.get("remove"):
|
|
122
|
+
entry = None
|
|
123
|
+
elif payload.get("off") is True:
|
|
124
|
+
entry = {"off": True}
|
|
125
|
+
elif _is_num(payload.get("session_tokens")) and payload["session_tokens"] > 0:
|
|
126
|
+
entry = {"session_tokens": payload["session_tokens"]}
|
|
127
|
+
else:
|
|
128
|
+
raise BadRequest("Enter a token limit above 0, or turn the guard off for this session.")
|
|
129
|
+
local = {}
|
|
130
|
+
if os.path.exists(LOCAL_SETTINGS_PATH):
|
|
131
|
+
with open(LOCAL_SETTINGS_PATH) as fh:
|
|
132
|
+
local = json.load(fh)
|
|
133
|
+
g = local.setdefault("guard", {})
|
|
134
|
+
sessions = dict(g.get("sessions") or {})
|
|
135
|
+
if entry is None:
|
|
136
|
+
sessions.pop(sid, None)
|
|
137
|
+
else:
|
|
138
|
+
sessions[sid] = dict(entry, set_at=time.strftime("%Y-%m-%dT%H:%M:%S"))
|
|
139
|
+
g["sessions"] = G.prune_sessions(sessions)
|
|
140
|
+
with open(LOCAL_SETTINGS_PATH, "w") as fh:
|
|
141
|
+
json.dump(local, fh, indent=2)
|
|
142
|
+
cur = load_settings()
|
|
143
|
+
with _lock:
|
|
144
|
+
A.settings = cur
|
|
145
|
+
return {"ok": True, "limit": g["sessions"].get(sid)}
|
|
146
|
+
|
|
147
|
+
|
|
65
148
|
MIME = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
|
66
149
|
".css": "text/css; charset=utf-8", ".json": "application/json",
|
|
67
150
|
".svg": "image/svg+xml", ".ico": "image/x-icon"}
|
|
@@ -218,6 +301,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
218
301
|
_validate_numeric_section(section, payload[section])
|
|
219
302
|
if "alert_thresholds_pct" in payload:
|
|
220
303
|
_validate_thresholds(payload["alert_thresholds_pct"])
|
|
304
|
+
if "guard" in payload:
|
|
305
|
+
_validate_guard(payload["guard"])
|
|
221
306
|
# UI edits go to the gitignored per-machine file, never the shared defaults
|
|
222
307
|
local = {}
|
|
223
308
|
if os.path.exists(LOCAL_SETTINGS_PATH):
|
|
@@ -225,7 +310,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
225
310
|
local = json.load(fh)
|
|
226
311
|
for k, v in payload.items():
|
|
227
312
|
if k in ("budgets", "limits", "alert_thresholds_pct", "waste_rules",
|
|
228
|
-
"anomaly", "scorecard", "account", "billing_period"):
|
|
313
|
+
"anomaly", "scorecard", "account", "billing_period", "guard"):
|
|
229
314
|
if isinstance(v, dict) and isinstance(local.get(k), dict):
|
|
230
315
|
local[k].update(v)
|
|
231
316
|
else:
|
|
@@ -238,7 +323,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
238
323
|
return self.send_json({"ok": True, "settings": cur})
|
|
239
324
|
self.send_json({"error": "unknown endpoint"}, 404)
|
|
240
325
|
except BadRequest as e:
|
|
241
|
-
self.send_json({"error": str(e)}, 400)
|
|
326
|
+
self.send_json({"error": str(e)}, 404 if isinstance(e, NotFound) else 400)
|
|
242
327
|
except Exception:
|
|
243
328
|
log.exception("POST %s failed", path)
|
|
244
329
|
self.send_json({"error": "internal error; see data/server.log"}, 500)
|
|
@@ -273,6 +358,34 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
273
358
|
if parts == ["statusline"]:
|
|
274
359
|
from .integrate import install_statusline
|
|
275
360
|
return self.send_json(install_statusline())
|
|
361
|
+
if len(parts) == 2 and parts[0] == "session_limit":
|
|
362
|
+
return self.send_json(_set_session_limit(parts[1], payload))
|
|
363
|
+
if parts in (["jev", "install"], ["jev", "uninstall"]):
|
|
364
|
+
from . import jev
|
|
365
|
+
return self.send_json({"job": X._job(jev.install if parts[1] == "install" else jev.uninstall)["id"]})
|
|
366
|
+
if parts == ["jev", "key"]:
|
|
367
|
+
from . import jev
|
|
368
|
+
try:
|
|
369
|
+
if payload.get("remove"):
|
|
370
|
+
jev.remove_key()
|
|
371
|
+
else:
|
|
372
|
+
jev.save_key(payload.get("value") if isinstance(payload.get("value"), str) else "")
|
|
373
|
+
except ValueError as e:
|
|
374
|
+
raise BadRequest(str(e))
|
|
375
|
+
return self.send_json({"ok": True, "key": jev.key_status()})
|
|
376
|
+
if len(parts) == 2 and parts[0] == "key":
|
|
377
|
+
from .cloud import save_key, key_status
|
|
378
|
+
value = payload.get("value")
|
|
379
|
+
if not payload.get("remove") and not (isinstance(value, str) and value.strip()):
|
|
380
|
+
raise BadRequest("Paste a key, or use Remove.")
|
|
381
|
+
try:
|
|
382
|
+
save_key(parts[1], "" if payload.get("remove") else value)
|
|
383
|
+
except ValueError as e:
|
|
384
|
+
raise BadRequest(str(e))
|
|
385
|
+
return self.send_json({"ok": True, "keys": key_status()})
|
|
386
|
+
if parts == ["guard"]:
|
|
387
|
+
from .integrate import install_guard
|
|
388
|
+
return self.send_json(install_guard(remove=bool(payload.get("remove"))))
|
|
276
389
|
if len(parts) == 2 and parts[0] == "mcp":
|
|
277
390
|
return self.send_json(X.add_mcp(parts[1], payload))
|
|
278
391
|
return self.send_json({"error": "unknown action"}, 404)
|
|
@@ -357,7 +470,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
357
470
|
except BrokenPipeError:
|
|
358
471
|
pass
|
|
359
472
|
except BadRequest as e:
|
|
360
|
-
self.send_json({"error": str(e) or "bad request"}, 400)
|
|
473
|
+
self.send_json({"error": str(e) or "bad request"}, 404 if isinstance(e, NotFound) else 400)
|
|
361
474
|
except Exception:
|
|
362
475
|
log.exception("GET %s failed", path)
|
|
363
476
|
self.send_json({"error": "internal error; see data/server.log"}, 500)
|
|
@@ -382,6 +495,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
382
495
|
from .cloud import configured
|
|
383
496
|
o = a.options()
|
|
384
497
|
o["cloud"] = configured() # nav hides "Billed vs local" until a key exists
|
|
498
|
+
from .integrate import guard_state
|
|
499
|
+
try:
|
|
500
|
+
o["guard_installed"] = guard_state() == "installed"
|
|
501
|
+
except Exception:
|
|
502
|
+
o["guard_installed"] = False
|
|
385
503
|
return self.send_json(o)
|
|
386
504
|
if route == "by_agent":
|
|
387
505
|
return self.send_json(a.by_agent(f))
|
|
@@ -457,6 +575,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
457
575
|
if not want or any(x != "claude" for x in want) else []
|
|
458
576
|
rows = sorted(claude + others, key=lambda x: -(x.get("context") or 0))
|
|
459
577
|
return self.send_json({"sessions": rows, "actions": list(ACTIONS)})
|
|
578
|
+
if route.startswith("guard/session/"):
|
|
579
|
+
return self.send_json(_session_limit_status(a, route[len("guard/session/"):]))
|
|
580
|
+
if route == "jev":
|
|
581
|
+
from . import jev
|
|
582
|
+
return self.send_json({"status": jev.status(), "key": jev.key_status(), "fit": jev.fit(a, f)})
|
|
583
|
+
if route == "keys":
|
|
584
|
+
from .cloud import key_status
|
|
585
|
+
return self.send_json(key_status())
|
|
460
586
|
if route == "cloud":
|
|
461
587
|
from .cloud import report
|
|
462
588
|
return self.send_json(report(a, _int(qs, "days", 30)))
|
package/finops/cloud.py
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
Everything else in this dashboard reads local files. This module is the one part that
|
|
4
4
|
talks to the internet, and only when you ask it to (the Refresh button / --cloud-sync).
|
|
5
5
|
|
|
6
|
-
Keys
|
|
7
|
-
|
|
8
|
-
outside the install tree, never packaged)
|
|
6
|
+
Keys come from the environment, or are stored with `claude-finops --set-key` or the
|
|
7
|
+
API keys section on the Budgets page. Both write ~/.claude-finops/secrets.local.json
|
|
8
|
+
(0600, outside the install tree, never packaged); the dashboard never sends a stored
|
|
9
|
+
key back to the browser, only whether it is set and its last four characters:
|
|
9
10
|
|
|
10
11
|
{"anthropic_admin_key": "sk-ant-admin...", "cursor_api_key": "key_..."}
|
|
11
12
|
|
|
@@ -65,6 +66,53 @@ def configured():
|
|
|
65
66
|
return {k: bool(key_for(k)) for k in PROVIDERS}
|
|
66
67
|
|
|
67
68
|
|
|
69
|
+
# A light shape check, to catch pasting the wrong thing: not a validity test.
|
|
70
|
+
KEY_SHAPES = {
|
|
71
|
+
"anthropic": (lambda v: v.startswith("sk-ant-admin"),
|
|
72
|
+
"An Anthropic Admin key starts with sk-ant-admin (a regular API key will not work)."),
|
|
73
|
+
"cursor": (lambda v: len(v) >= 16, "That looks too short for a Cursor Admin API key."),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def key_status():
|
|
78
|
+
"""Per provider: where the key comes from and its last four characters. Never the key."""
|
|
79
|
+
stored = _secrets()
|
|
80
|
+
out = {}
|
|
81
|
+
for pid, p in PROVIDERS.items():
|
|
82
|
+
env, val = os.environ.get(p["env"]), stored.get(p["field"])
|
|
83
|
+
src = "env" if env else "stored" if val else None
|
|
84
|
+
key = env or val or ""
|
|
85
|
+
out[pid] = {"name": p["name"], "env": p["env"], "how": p["how"], "covers": p["covers"],
|
|
86
|
+
"source": src, "last4": key[-4:] if len(key) >= 12 else None}
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def save_key(provider, value):
|
|
91
|
+
"""Store (value) or remove (empty value) one provider key in the 0600 secrets file.
|
|
92
|
+
|
|
93
|
+
Raises ValueError for an unknown provider or a value that fails the shape check.
|
|
94
|
+
"""
|
|
95
|
+
if provider not in PROVIDERS:
|
|
96
|
+
raise ValueError(f"unknown provider {provider!r}")
|
|
97
|
+
value = (value or "").strip()
|
|
98
|
+
if value:
|
|
99
|
+
if any(ch.isspace() for ch in value):
|
|
100
|
+
raise ValueError("A key has no spaces or line breaks in it.")
|
|
101
|
+
ok, msg = KEY_SHAPES.get(provider, (lambda v: True, ""))
|
|
102
|
+
if not ok(value):
|
|
103
|
+
raise ValueError(msg)
|
|
104
|
+
data = _secrets()
|
|
105
|
+
if value:
|
|
106
|
+
data[PROVIDERS[provider]["field"]] = value
|
|
107
|
+
else:
|
|
108
|
+
data.pop(PROVIDERS[provider]["field"], None)
|
|
109
|
+
os.makedirs(os.path.dirname(SECRETS_PATH), exist_ok=True)
|
|
110
|
+
fd = os.open(SECRETS_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
111
|
+
with os.fdopen(fd, "w") as fh:
|
|
112
|
+
json.dump(data, fh, indent=2)
|
|
113
|
+
os.chmod(SECRETS_PATH, 0o600) # O_CREAT's mode only applies to a new file
|
|
114
|
+
|
|
115
|
+
|
|
68
116
|
def _get(url, headers, data=None, method="GET"):
|
|
69
117
|
req = urllib.request.Request(url, data=data, method=method,
|
|
70
118
|
headers={"User-Agent": UA, **headers})
|