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 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** view edits limits, budgets and thresholds from the browser and writes
214
- them back to this file.
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
 
@@ -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,
@@ -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 are never stored by the UI. Put them in the environment, or store one with
7
- `claude-finops --set-key`, which writes ~/.claude-finops/secrets.local.json (0600,
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})