claude-finops 0.13.0 → 0.15.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/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"}
@@ -111,6 +194,14 @@ def filters_from(qs):
111
194
  return f
112
195
 
113
196
 
197
+ def _costs_by_day_model(a, f):
198
+ """One row per day and model: the cost export, distinct from the usage timeline."""
199
+ w, p = a.where(f)
200
+ return a.q(f"""SELECT r.day, r.model, COUNT(*) requests, SUM(r.billable_tokens) tokens,
201
+ SUM(r.est_cost_usd) est_cost_usd FROM requests r
202
+ WHERE {w} AND r.day <> '' GROUP BY 1, 2 ORDER BY 1, 5 DESC""", p)
203
+
204
+
114
205
  def flatten(rows):
115
206
  keys = []
116
207
  for r in rows:
@@ -218,6 +309,8 @@ class Handler(BaseHTTPRequestHandler):
218
309
  _validate_numeric_section(section, payload[section])
219
310
  if "alert_thresholds_pct" in payload:
220
311
  _validate_thresholds(payload["alert_thresholds_pct"])
312
+ if "guard" in payload:
313
+ _validate_guard(payload["guard"])
221
314
  # UI edits go to the gitignored per-machine file, never the shared defaults
222
315
  local = {}
223
316
  if os.path.exists(LOCAL_SETTINGS_PATH):
@@ -225,7 +318,7 @@ class Handler(BaseHTTPRequestHandler):
225
318
  local = json.load(fh)
226
319
  for k, v in payload.items():
227
320
  if k in ("budgets", "limits", "alert_thresholds_pct", "waste_rules",
228
- "anomaly", "scorecard", "account", "billing_period"):
321
+ "anomaly", "scorecard", "account", "billing_period", "guard"):
229
322
  if isinstance(v, dict) and isinstance(local.get(k), dict):
230
323
  local[k].update(v)
231
324
  else:
@@ -238,7 +331,7 @@ class Handler(BaseHTTPRequestHandler):
238
331
  return self.send_json({"ok": True, "settings": cur})
239
332
  self.send_json({"error": "unknown endpoint"}, 404)
240
333
  except BadRequest as e:
241
- self.send_json({"error": str(e)}, 400)
334
+ self.send_json({"error": str(e)}, 404 if isinstance(e, NotFound) else 400)
242
335
  except Exception:
243
336
  log.exception("POST %s failed", path)
244
337
  self.send_json({"error": "internal error; see data/server.log"}, 500)
@@ -252,10 +345,7 @@ class Handler(BaseHTTPRequestHandler):
252
345
  """POST /api/do/...: actions that change this machine. Always user-initiated."""
253
346
  from . import actions as X
254
347
  if parts == ["sync"]:
255
- cur = X.JOBS.get(_SYNC["job"] or "")
256
- if not cur or cur["state"] != "running":
257
- _SYNC["job"] = X._job(_sync_job)["id"]
258
- return self.send_json({"job": _SYNC["job"]})
348
+ return self.send_json({"job": _start_sync()})
259
349
  if len(parts) == 3 and parts[0] == "free_model" and parts[2] == "install":
260
350
  j = X._job(X.free_model_install, parts[1], bool(payload.get("consent")),
261
351
  payload.get("api_key"))
@@ -273,6 +363,44 @@ class Handler(BaseHTTPRequestHandler):
273
363
  if parts == ["statusline"]:
274
364
  from .integrate import install_statusline
275
365
  return self.send_json(install_statusline())
366
+ if len(parts) == 2 and parts[0] == "session_limit":
367
+ return self.send_json(_set_session_limit(parts[1], payload))
368
+ if parts in (["jev", "install"], ["jev", "uninstall"]):
369
+ from . import jev
370
+ return self.send_json({"job": X._job(jev.install if parts[1] == "install" else jev.uninstall)["id"]})
371
+ if parts == ["jev", "key"]:
372
+ from . import jev
373
+ try:
374
+ if payload.get("remove"):
375
+ jev.remove_key()
376
+ else:
377
+ jev.save_key(payload.get("value") if isinstance(payload.get("value"), str) else "")
378
+ except ValueError as e:
379
+ raise BadRequest(str(e))
380
+ return self.send_json({"ok": True, "key": jev.key_status()})
381
+ if len(parts) == 2 and parts[0] == "key":
382
+ from .cloud import save_key, key_status
383
+ value = payload.get("value")
384
+ if not payload.get("remove") and not (isinstance(value, str) and value.strip()):
385
+ raise BadRequest("Paste a key, or use Remove.")
386
+ try:
387
+ save_key(parts[1], "" if payload.get("remove") else value)
388
+ except ValueError as e:
389
+ raise BadRequest(str(e))
390
+ return self.send_json({"ok": True, "keys": key_status()})
391
+ if parts == ["guard"]:
392
+ from .integrate import install_guard
393
+ return self.send_json(install_guard(remove=bool(payload.get("remove"))))
394
+ if parts == ["subagent_experiment"]:
395
+ from . import subagents as SA
396
+ try:
397
+ if payload.get("action") == "start":
398
+ return self.send_json(SA.start_experiment(payload.get("model")))
399
+ if payload.get("action") == "stop":
400
+ return self.send_json(SA.stop_experiment())
401
+ except (ValueError, SA.SettingsError) as e:
402
+ raise BadRequest(str(e))
403
+ raise BadRequest("action must be start or stop")
276
404
  if len(parts) == 2 and parts[0] == "mcp":
277
405
  return self.send_json(X.add_mcp(parts[1], payload))
278
406
  return self.send_json({"error": "unknown action"}, 404)
@@ -288,12 +416,12 @@ class Handler(BaseHTTPRequestHandler):
288
416
  with _lock:
289
417
  a = A
290
418
  qs = parse_qs(urlparse(self.path).query)
291
- ags = [x for x in (qs.get("agents", [""])[0]).split(",") if x]
292
- return self.send_json(X.compare(a, ags or None))
419
+ f = filters_from(qs)
420
+ return self.send_json(X.compare(a, f["agents"] or None, f))
293
421
  if parts == ["suggestions"]:
294
422
  with _lock:
295
423
  a = A
296
- return self.send_json(X.suggestions(a))
424
+ return self.send_json(X.suggestions(a, filters_from(parse_qs(urlparse(self.path).query))))
297
425
  if parts[0] == "job" and len(parts) == 2:
298
426
  return self.send_json(X.job_status(parts[1]))
299
427
  if parts == ["sync"]:
@@ -357,7 +485,7 @@ class Handler(BaseHTTPRequestHandler):
357
485
  except BrokenPipeError:
358
486
  pass
359
487
  except BadRequest as e:
360
- self.send_json({"error": str(e) or "bad request"}, 400)
488
+ self.send_json({"error": str(e) or "bad request"}, 404 if isinstance(e, NotFound) else 400)
361
489
  except Exception:
362
490
  log.exception("GET %s failed", path)
363
491
  self.send_json({"error": "internal error; see data/server.log"}, 500)
@@ -382,6 +510,11 @@ class Handler(BaseHTTPRequestHandler):
382
510
  from .cloud import configured
383
511
  o = a.options()
384
512
  o["cloud"] = configured() # nav hides "Billed vs local" until a key exists
513
+ from .integrate import guard_state
514
+ try:
515
+ o["guard_installed"] = guard_state() == "installed"
516
+ except Exception:
517
+ o["guard_installed"] = False
385
518
  return self.send_json(o)
386
519
  if route == "by_agent":
387
520
  return self.send_json(a.by_agent(f))
@@ -457,6 +590,17 @@ class Handler(BaseHTTPRequestHandler):
457
590
  if not want or any(x != "claude" for x in want) else []
458
591
  rows = sorted(claude + others, key=lambda x: -(x.get("context") or 0))
459
592
  return self.send_json({"sessions": rows, "actions": list(ACTIONS)})
593
+ if route.startswith("guard/session/"):
594
+ return self.send_json(_session_limit_status(a, route[len("guard/session/"):]))
595
+ if route == "subagents":
596
+ from .subagents import report
597
+ return self.send_json(report(a, f))
598
+ if route == "jev":
599
+ from . import jev
600
+ return self.send_json({"status": jev.status(), "key": jev.key_status(), "fit": jev.fit(a, f)})
601
+ if route == "keys":
602
+ from .cloud import key_status
603
+ return self.send_json(key_status())
460
604
  if route == "cloud":
461
605
  from .cloud import report
462
606
  return self.send_json(report(a, _int(qs, "days", 30)))
@@ -469,13 +613,15 @@ class Handler(BaseHTTPRequestHandler):
469
613
  pid = int(route.split("/")[1])
470
614
  except ValueError:
471
615
  raise BadRequest("id must be an integer")
472
- return self.send_json(a.prompt_detail(pid))
616
+ d = a.prompt_detail(pid, f)
617
+ return self.send_json(d, 404 if isinstance(d, dict) and d.get("error") else 200)
473
618
  if route.startswith("session/"):
474
- return self.send_json(a.session_detail(route.split("/", 1)[1]))
619
+ d = a.session_detail(route.split("/", 1)[1], f)
620
+ return self.send_json(d, 404 if isinstance(d, dict) and d.get("error") else 200)
475
621
  if route == "bundle":
476
622
  return self.send_json({
477
623
  "overview": a.overview(f), "burn": a.burn(f), "timeline": a.timeline(f),
478
- "models": a.models(f), "projects": a.projects(f)[:40],
624
+ "models": a.models(f), "projects": a.projects(f),
479
625
  "categories": a.categories(f), "efficiency": a.efficiency(f),
480
626
  "context": a.context_analysis(f), "waste": a.waste(f),
481
627
  "recommendations": a.recommendations(f), "forecast": a.forecast(f),
@@ -497,8 +643,9 @@ class Handler(BaseHTTPRequestHandler):
497
643
  "usage": lambda: a.timeline(f),
498
644
  "models": lambda: a.models(f)["rows"],
499
645
  "projects": lambda: a.projects(f),
500
- "costs": lambda: a.timeline(f),
501
- "waste": lambda: [dict(x, evidence=len(x["evidence"])) for x in a.waste(f)["findings"]],
646
+ "costs": lambda: _costs_by_day_model(a, f),
647
+ "waste": lambda: [dict(x, evidence=x.get("count", len(x["evidence"])))
648
+ for x in a.waste(f)["findings"]],
502
649
  "recommendations": lambda: a.recommendations(f)["recommendations"],
503
650
  "forecast": lambda: [dict(name=k, **v) for k, v in
504
651
  (a.forecast(f).get("scenarios") or {}).items()],
@@ -570,6 +717,36 @@ def _sync_job(log):
570
717
  return {"built_at": A.q("SELECT value FROM meta WHERE key='built_at'")[0]["value"]}
571
718
 
572
719
 
720
+ def _start_sync():
721
+ """Start a sync unless one is already running; returns the job id."""
722
+ from . import actions as X
723
+ cur = X.JOBS.get(_SYNC["job"] or "")
724
+ if not cur or cur["state"] != "running":
725
+ _SYNC["job"] = X._job(_sync_job)["id"]
726
+ return _SYNC["job"]
727
+
728
+
729
+ def _auto_sync_loop(minutes):
730
+ """Keep the warehouse fresh without anyone pressing Sync.
731
+
732
+ Every few minutes, if the last build is older than `minutes`, run the same sync the
733
+ Sync button runs. A stale warehouse is what made recent days read as idle and
734
+ projections read low; this keeps "today" honest on a dashboard left open.
735
+ """
736
+ import time as _t
737
+ from datetime import datetime as _dt, timezone as _tz
738
+ while True:
739
+ try:
740
+ with _lock:
741
+ built = A.meta.get("built_at") if A else None
742
+ age = (_dt.now(_tz.utc) - _dt.fromisoformat(built)).total_seconds() / 60 if built else 1e9
743
+ if age >= minutes:
744
+ _start_sync()
745
+ except Exception:
746
+ log.exception("auto-sync check failed")
747
+ _t.sleep(min(minutes, 15) * 60)
748
+
749
+
573
750
  def _notify_update():
574
751
  from .update import notify
575
752
  notify()
@@ -611,6 +788,12 @@ def serve(port=8787, db=DB_PATH, background=None):
611
788
  from .update import disabled
612
789
  if not disabled():
613
790
  threading.Thread(target=_notify_update, daemon=True).start()
791
+ try:
792
+ auto = float(os.environ.get("CLAUDE_FINOPS_AUTOSYNC_MINUTES", "60"))
793
+ except ValueError:
794
+ auto = 60.0
795
+ if auto > 0:
796
+ threading.Thread(target=_auto_sync_loop, args=(auto,), daemon=True).start()
614
797
  # The socket is already listening, so the page's first request just waits for
615
798
  # serve_forever. Off the main thread: some platforms block while the browser starts.
616
799
  threading.Thread(target=_open_browser, args=(f"http://127.0.0.1:{port}",), daemon=True).start()
@@ -13,6 +13,7 @@ CATEGORIES = [
13
13
  ("documentation", 2.3, r"\b(document|documentation|docs?|readme|changelog|comment(s)? (for|on)|docstring|write up|write-up)\b"),
14
14
  ("architecture", 2.2, r"\b(architect|architecture|design (the|a) (system|schema|api)|system design|data model|schema|scal(e|ing|ability)|trade[- ]?offs?|high level design)\b"),
15
15
  ("planning", 2.1, r"\b(plan|roadmap|break (this )?down|steps to|approach|strategy|estimate|milestone|backlog|prioriti[sz]e)\b"),
16
+ ("version_control", 2.0, r"\b(commit|push|merge|rebase|cherry[- ]?pick|stash|branch|git)\b"),
16
17
  ("automation", 2.0, r"\b(script|automat|cron|pipeline|ci/?cd|workflow|deploy|jenkins|github action|makefile|bash script)\b"),
17
18
  ("research", 1.9, r"\b(research|compare|find out|look up|investigate|what (is|are)|explore options|alternatives|pros and cons|benchmark|which (library|tool|framework))\b"),
18
19
  ("learning", 1.8, r"\b(explain|how does|teach me|understand|what does .* mean|walk me through|help me learn|tutorial|eli5)\b"),
@@ -42,6 +43,11 @@ def classify(text):
42
43
  scores[name] = weight * (1 + 0.25 * min(n - 1, 4))
43
44
  evidence[name] = sorted({s.lower().strip() for s in flat if s})[:5]
44
45
  if not scores:
46
+ # Most unmatched prompts are short steering turns inside a task ("do that",
47
+ # "fix all", "status?", or a reply in another language). They are not
48
+ # unknown work, they continue the turn before; say so rather than "other".
49
+ if len(text.strip()) < 160:
50
+ return "follow_up", 0.5, ["short instruction"]
45
51
  return "other", 0.0, []
46
52
  best = max(scores, key=scores.get)
47
53
  total = sum(scores.values())
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})
@@ -261,10 +309,13 @@ def report(analytics, days=30):
261
309
  cu = c.get("cursor_usage") or []
262
310
  cs = c.get("cursor_spend") or []
263
311
  since = (datetime.now(timezone.utc).date() - timedelta(days=days)).isoformat()
312
+ # The vendor APIs report UTC days, so this page compares on UTC days too (every
313
+ # other page uses your local calendar days); otherwise a late-evening session would
314
+ # sit on different dates on the two sides.
264
315
  local = {r["agent"]: r for r in analytics.q(
265
316
  "SELECT agent, SUM(est_cost_usd) cost, SUM(billable_tokens) tokens,"
266
317
  " COUNT(DISTINCT session_id) sessions, COUNT(*) requests"
267
- " FROM requests WHERE day >= ? GROUP BY agent", (since,))}
318
+ " FROM requests WHERE substr(ts,1,10) >= ? GROUP BY agent", (since,))}
268
319
 
269
320
  by_day = {}
270
321
  for r in cc:
@@ -273,9 +324,9 @@ def report(analytics, days=30):
273
324
  d["billed_cost"] += r["est_cost_usd"]
274
325
  d["billed_tokens"] += r["tokens"]
275
326
  d["billed_sessions"] += r["sessions"]
276
- for r in analytics.q("SELECT day, SUM(est_cost_usd) c, SUM(billable_tokens) t,"
327
+ for r in analytics.q("SELECT substr(ts,1,10) day, SUM(est_cost_usd) c, SUM(billable_tokens) t,"
277
328
  " COUNT(DISTINCT session_id) s FROM requests"
278
- " WHERE agent='claude' AND day >= ? GROUP BY day", (since,)):
329
+ " WHERE agent='claude' AND substr(ts,1,10) >= ? GROUP BY 1", (since,)):
279
330
  d = by_day.setdefault(r["day"], {"day": r["day"], "billed_cost": 0.0, "billed_tokens": 0,
280
331
  "billed_sessions": 0})
281
332
  d.update(local_cost=r["c"] or 0.0, local_tokens=r["t"] or 0, local_sessions=r["s"] or 0)
@@ -331,6 +382,7 @@ def report(analytics, days=30):
331
382
  for r in cost}.items()], key=lambda x: -x["cost_usd"])[:20],
332
383
  "note": "Billed figures come from the vendor APIs (org-wide, every machine and member). "
333
384
  "Local figures are what this machine's transcripts recorded. A gap usually means "
334
- "other machines, other members, or work outside this machine.",
385
+ "other machines, other members, or work outside this machine. Days on this "
386
+ "page are UTC days, as the vendors report them.",
335
387
  "basis": "billed",
336
388
  }
@@ -121,9 +121,13 @@ class Diagnoser:
121
121
  "basis": "actual tokens, estimated cost"})
122
122
 
123
123
  # 4. tool output flooding context
124
- tools = a.q(f"""SELECT tc.name, COUNT(*) calls, SUM(r.billable_tokens) b
125
- FROM tool_calls tc JOIN requests r ON r.id=tc.request_pk WHERE {w}
126
- GROUP BY tc.name ORDER BY b DESC LIMIT 6""", p)
124
+ # Tokens per tool count each request once: a request that made three Bash calls
125
+ # was still one request, and joining calls to requests counted it three times.
126
+ tools = a.q(f"""SELECT x.name, SUM(x.calls) calls, SUM(r.billable_tokens) b
127
+ FROM (SELECT tc.request_pk, tc.name, COUNT(*) calls FROM tool_calls tc
128
+ GROUP BY tc.request_pk, tc.name) x
129
+ JOIN requests r ON r.id=x.request_pk WHERE {w}
130
+ GROUP BY x.name ORDER BY b DESC LIMIT 6""", p)
127
131
  if tools:
128
132
  top = tools[0]
129
133
  out.append({
@@ -585,8 +589,17 @@ class Diagnoser:
585
589
  MAX(tc.result_chars)/{CHARS_PER_TOKEN} largest
586
590
  FROM tool_calls tc WHERE tc.kind IN ({ph}) AND {tcw}
587
591
  GROUP BY tc.kind, tc.server ORDER BY carried DESC""", kinds + p)
592
+ # Re-reads are priced at the cache-read rate of the model that made each call,
593
+ # not one main-model rate for everything.
594
+ cost_by = defaultdict(float)
595
+ for x in a.q(f"""SELECT tc.kind, tc.server, rq.model,
596
+ SUM(tc.result_chars*1.0*tc.carry_requests)/{CHARS_PER_TOKEN} carried
597
+ FROM tool_calls tc JOIN requests rq ON rq.id=tc.request_pk
598
+ WHERE tc.kind IN ({ph}) AND {tcw} GROUP BY 1, 2, 3""", kinds + p):
599
+ r_ = (a.pricing.rates(x["model"]).get("cache_read") or 0) / 1e6
600
+ cost_by[(x["kind"], x["server"])] += (x["carried"] or 0) * r_
588
601
  for r in rows:
589
- r["carried_cost"] = (r["carried"] or 0) * rate
602
+ r["carried_cost"] = cost_by.get((r["kind"], r["server"]), (r["carried"] or 0) * rate)
590
603
  r["tools"] = a.q(f"""SELECT tc.name, COUNT(*) calls,
591
604
  SUM(tc.result_chars)/{CHARS_PER_TOKEN} injected,
592
605
  SUM(tc.result_chars*1.0*tc.carry_requests)/{CHARS_PER_TOKEN} carried
@@ -622,7 +635,7 @@ class Diagnoser:
622
635
  "note": "Injected = size of what came back into context (actual size, ~4 chars/token). "
623
636
  "Carried = injected × later requests in the same session that re-read it — an "
624
637
  "upper bound, since /compact isn't visible. Carried cost prices those re-reads at "
625
- "your main model's cache-read rate (estimated). Subagent tokens are actual. "
638
+ "the cache-read rate of the model that made each call (estimated). Subagent tokens are actual. "
626
639
  "Slash-command figures attribute the whole turn they started.",
627
640
  }
628
641