claude-finops 0.8.0 → 0.9.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/config/pricing.json +124 -2
- package/finops/agents.py +2 -1
- package/finops/analytics.py +31 -0
- package/finops/api.py +5 -0
- package/finops/etl.py +20 -5
- package/finops/paths.py +12 -0
- package/finops/plan_history.py +80 -0
- package/package.json +1 -1
- package/web/app.js +44 -4
- package/web/charts.js +34 -1
package/config/pricing.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"_comment": "Model pricing in USD per 1,000,000 tokens. Kept SEPARATE from usage data so it can be updated without touching the dashboard. Any cost derived from these numbers is ESTIMATED, never actual billed cost.",
|
|
3
3
|
"currency": "USD",
|
|
4
4
|
"unit": "per_million_tokens",
|
|
5
|
-
"updated": "2026-09-
|
|
6
|
-
"source": "Anthropic list prices (
|
|
5
|
+
"updated": "2026-09-28",
|
|
6
|
+
"source": "Anthropic list prices (platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-28)",
|
|
7
7
|
"models": {
|
|
8
8
|
"claude-opus-5": {
|
|
9
9
|
"display_name": "Claude Opus 5",
|
|
@@ -84,6 +84,128 @@
|
|
|
84
84
|
"context_window": 1000000,
|
|
85
85
|
"provider": "anthropic"
|
|
86
86
|
},
|
|
87
|
+
"claude-opus-4-8": {
|
|
88
|
+
"display_name": "Claude Opus 4.8",
|
|
89
|
+
"tier": "frontier",
|
|
90
|
+
"input": 5.0,
|
|
91
|
+
"output": 25.0,
|
|
92
|
+
"cache_write_5m": 6.25,
|
|
93
|
+
"cache_write_1h": 10.0,
|
|
94
|
+
"cache_read": 0.5,
|
|
95
|
+
"context_window": 1000000,
|
|
96
|
+
"provider": "anthropic"
|
|
97
|
+
},
|
|
98
|
+
"claude-opus-4-8[fast]": {
|
|
99
|
+
"display_name": "Claude Opus 4.8 (fast mode)",
|
|
100
|
+
"tier": "frontier",
|
|
101
|
+
"input": 10.0,
|
|
102
|
+
"output": 50.0,
|
|
103
|
+
"cache_write_5m": 12.5,
|
|
104
|
+
"cache_write_1h": 20.0,
|
|
105
|
+
"cache_read": 1.0,
|
|
106
|
+
"context_window": 1000000,
|
|
107
|
+
"provider": "anthropic",
|
|
108
|
+
"_note": "Research-preview fast mode, 2x standard. Selected when usage.speed == 'fast'."
|
|
109
|
+
},
|
|
110
|
+
"claude-opus-4-7": {
|
|
111
|
+
"display_name": "Claude Opus 4.7",
|
|
112
|
+
"tier": "frontier",
|
|
113
|
+
"input": 5.0,
|
|
114
|
+
"output": 25.0,
|
|
115
|
+
"cache_write_5m": 6.25,
|
|
116
|
+
"cache_write_1h": 10.0,
|
|
117
|
+
"cache_read": 0.5,
|
|
118
|
+
"context_window": 1000000,
|
|
119
|
+
"provider": "anthropic"
|
|
120
|
+
},
|
|
121
|
+
"claude-opus-4-6": {
|
|
122
|
+
"display_name": "Claude Opus 4.6",
|
|
123
|
+
"tier": "frontier",
|
|
124
|
+
"input": 5.0,
|
|
125
|
+
"output": 25.0,
|
|
126
|
+
"cache_write_5m": 6.25,
|
|
127
|
+
"cache_write_1h": 10.0,
|
|
128
|
+
"cache_read": 0.5,
|
|
129
|
+
"context_window": 1000000,
|
|
130
|
+
"provider": "anthropic"
|
|
131
|
+
},
|
|
132
|
+
"claude-opus-4-5": {
|
|
133
|
+
"display_name": "Claude Opus 4.5",
|
|
134
|
+
"tier": "frontier",
|
|
135
|
+
"input": 5.0,
|
|
136
|
+
"output": 25.0,
|
|
137
|
+
"cache_write_5m": 6.25,
|
|
138
|
+
"cache_write_1h": 10.0,
|
|
139
|
+
"cache_read": 0.5,
|
|
140
|
+
"context_window": 200000,
|
|
141
|
+
"provider": "anthropic"
|
|
142
|
+
},
|
|
143
|
+
"claude-opus-4-1": {
|
|
144
|
+
"display_name": "Claude Opus 4.1",
|
|
145
|
+
"tier": "frontier",
|
|
146
|
+
"input": 15.0,
|
|
147
|
+
"output": 75.0,
|
|
148
|
+
"cache_write_5m": 18.75,
|
|
149
|
+
"cache_write_1h": 30.0,
|
|
150
|
+
"cache_read": 1.5,
|
|
151
|
+
"context_window": 200000,
|
|
152
|
+
"provider": "anthropic"
|
|
153
|
+
},
|
|
154
|
+
"claude-opus-4": {
|
|
155
|
+
"display_name": "Claude Opus 4",
|
|
156
|
+
"tier": "frontier",
|
|
157
|
+
"input": 15.0,
|
|
158
|
+
"output": 75.0,
|
|
159
|
+
"cache_write_5m": 18.75,
|
|
160
|
+
"cache_write_1h": 30.0,
|
|
161
|
+
"cache_read": 1.5,
|
|
162
|
+
"context_window": 200000,
|
|
163
|
+
"provider": "anthropic"
|
|
164
|
+
},
|
|
165
|
+
"claude-sonnet-4-6": {
|
|
166
|
+
"display_name": "Claude Sonnet 4.6",
|
|
167
|
+
"tier": "balanced",
|
|
168
|
+
"input": 3.0,
|
|
169
|
+
"output": 15.0,
|
|
170
|
+
"cache_write_5m": 3.75,
|
|
171
|
+
"cache_write_1h": 6.0,
|
|
172
|
+
"cache_read": 0.3,
|
|
173
|
+
"context_window": 1000000,
|
|
174
|
+
"provider": "anthropic"
|
|
175
|
+
},
|
|
176
|
+
"claude-sonnet-4-5": {
|
|
177
|
+
"display_name": "Claude Sonnet 4.5",
|
|
178
|
+
"tier": "balanced",
|
|
179
|
+
"input": 3.0,
|
|
180
|
+
"output": 15.0,
|
|
181
|
+
"cache_write_5m": 3.75,
|
|
182
|
+
"cache_write_1h": 6.0,
|
|
183
|
+
"cache_read": 0.3,
|
|
184
|
+
"context_window": 200000,
|
|
185
|
+
"provider": "anthropic"
|
|
186
|
+
},
|
|
187
|
+
"claude-sonnet-4": {
|
|
188
|
+
"display_name": "Claude Sonnet 4",
|
|
189
|
+
"tier": "balanced",
|
|
190
|
+
"input": 3.0,
|
|
191
|
+
"output": 15.0,
|
|
192
|
+
"cache_write_5m": 3.75,
|
|
193
|
+
"cache_write_1h": 6.0,
|
|
194
|
+
"cache_read": 0.3,
|
|
195
|
+
"context_window": 200000,
|
|
196
|
+
"provider": "anthropic"
|
|
197
|
+
},
|
|
198
|
+
"claude-3-5-haiku": {
|
|
199
|
+
"display_name": "Claude Haiku 3.5",
|
|
200
|
+
"tier": "economy",
|
|
201
|
+
"input": 0.8,
|
|
202
|
+
"output": 4.0,
|
|
203
|
+
"cache_write_5m": 1.0,
|
|
204
|
+
"cache_write_1h": 1.6,
|
|
205
|
+
"cache_read": 0.08,
|
|
206
|
+
"context_window": 200000,
|
|
207
|
+
"provider": "anthropic"
|
|
208
|
+
},
|
|
87
209
|
"claude-haiku-4-5-20251001": {
|
|
88
210
|
"display_name": "Claude Haiku 4.5",
|
|
89
211
|
"tier": "economy",
|
package/finops/agents.py
CHANGED
|
@@ -33,7 +33,8 @@ def _cursor_state_db():
|
|
|
33
33
|
|
|
34
34
|
AGENTS = {
|
|
35
35
|
"claude": {"name": "Claude Code", "data": "full",
|
|
36
|
-
"note": "Tokens, model, cost, prompts and tool calls per request."
|
|
36
|
+
"note": "Tokens, model, cost, prompts and tool calls per request. "
|
|
37
|
+
"Includes Claude desktop app (Cowork) sessions when present."},
|
|
37
38
|
"codex": {"name": "Codex", "data": "tokens",
|
|
38
39
|
"paths": [os.path.join(HOME, ".codex", "sessions")],
|
|
39
40
|
"note": "Tokens and model per turn. Cost estimated at OpenAI API list prices; "
|
package/finops/analytics.py
CHANGED
|
@@ -12,6 +12,7 @@ import sqlite3
|
|
|
12
12
|
import statistics
|
|
13
13
|
import sys
|
|
14
14
|
import threading
|
|
15
|
+
import time
|
|
15
16
|
from collections import defaultdict
|
|
16
17
|
from datetime import date, datetime, timedelta, timezone
|
|
17
18
|
|
|
@@ -334,6 +335,7 @@ class Analytics:
|
|
|
334
335
|
f" FROM requests r WHERE {w} AND {extra}", p + ep)
|
|
335
336
|
|
|
336
337
|
tot["cost_today"] = spend("r.day = ?", [today])
|
|
338
|
+
tot["cost_yesterday"] = spend("r.day = ?", [(_d(today) - timedelta(days=1)).isoformat()])
|
|
337
339
|
tot["cost_week"] = spend("r.day >= ?", [wk])
|
|
338
340
|
tot["cost_period"] = spend("r.day >= ? AND r.day <= ?", [bp["start"], bp["end"]])
|
|
339
341
|
tot["billing_period"] = bp
|
|
@@ -443,6 +445,30 @@ class Analytics:
|
|
|
443
445
|
AVG(r.context_tokens) avg_context
|
|
444
446
|
FROM requests r WHERE {w} AND r.day <> '' GROUP BY 1 ORDER BY 1""", p)
|
|
445
447
|
|
|
448
|
+
def heatmap(self, f=None):
|
|
449
|
+
"""Spend by weekday x hour, in this machine's local time (Monday first).
|
|
450
|
+
|
|
451
|
+
Transcripts stamp UTC; bucketing on that would put a 10am IST session at
|
|
452
|
+
4am. SQLite's 'localtime' modifier applies the local offset, half-hour
|
|
453
|
+
zones included. Date filters still apply to UTC days like every view.
|
|
454
|
+
"""
|
|
455
|
+
w, p = self.where(f)
|
|
456
|
+
rows = self.q(f"""
|
|
457
|
+
SELECT CAST(strftime('%w', r.ts, 'localtime') AS INTEGER) wd,
|
|
458
|
+
CAST(strftime('%H', r.ts, 'localtime') AS INTEGER) hr,
|
|
459
|
+
COALESCE(SUM(r.est_cost_usd),0) cost, COALESCE(SUM(r.billable_tokens),0) tokens,
|
|
460
|
+
COUNT(*) requests
|
|
461
|
+
FROM requests r WHERE {w} AND r.ts <> '' GROUP BY 1, 2""", p)
|
|
462
|
+
grid = {(d, h): {"dow": d, "hour": h, "cost": 0.0, "tokens": 0, "requests": 0}
|
|
463
|
+
for d in range(7) for h in range(24)}
|
|
464
|
+
for r in rows:
|
|
465
|
+
if r["wd"] is None or r["hr"] is None:
|
|
466
|
+
continue
|
|
467
|
+
c = grid[((r["wd"] + 6) % 7, r["hr"])]
|
|
468
|
+
c["cost"], c["tokens"], c["requests"] = r["cost"], r["tokens"], r["requests"]
|
|
469
|
+
return {"cells": [grid[(d, h)] for d in range(7) for h in range(24)],
|
|
470
|
+
"tz": time.strftime("%Z"), "cost_basis": "estimated"}
|
|
471
|
+
|
|
446
472
|
# ---------------- models ----------------
|
|
447
473
|
def models(self, f=None):
|
|
448
474
|
w, p = self.where(f)
|
|
@@ -673,6 +699,11 @@ class Analytics:
|
|
|
673
699
|
s["files"] = self.q(
|
|
674
700
|
"SELECT path, GROUP_CONCAT(DISTINCT op) ops, COUNT(*) n FROM files_touched"
|
|
675
701
|
" WHERE session_id=? GROUP BY path ORDER BY n DESC LIMIT 100", (sid,))
|
|
702
|
+
# Cowork transcripts live under the desktop app's own config dir, where
|
|
703
|
+
# `claude --resume` would not find them.
|
|
704
|
+
cowork = "local-agent-mode-sessions" in (s.get("source_file") or "")
|
|
705
|
+
s["resume"] = (f"claude --resume {sid}"
|
|
706
|
+
if (s.get("agent") or "claude") == "claude" and not cowork else None)
|
|
676
707
|
return s
|
|
677
708
|
|
|
678
709
|
# ---------------- categories ----------------
|
package/finops/api.py
CHANGED
|
@@ -366,6 +366,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
366
366
|
if route == "usage":
|
|
367
367
|
from .limits import usage
|
|
368
368
|
return self.send_json(usage(force=qs.get("refresh", [""])[0] == "1"))
|
|
369
|
+
if route == "plan_history":
|
|
370
|
+
from .plan_history import history
|
|
371
|
+
return self.send_json(history())
|
|
369
372
|
if route.startswith(("free_models", "suggestions", "job/", "sync", "compare")):
|
|
370
373
|
return self.actions_get(route)
|
|
371
374
|
f = filters_from(qs)
|
|
@@ -385,6 +388,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
385
388
|
return self.send_json(a.burn(f))
|
|
386
389
|
if route == "timeline":
|
|
387
390
|
return self.send_json(a.timeline(f, g("grain", "day")))
|
|
391
|
+
if route == "heatmap":
|
|
392
|
+
return self.send_json(a.heatmap(f))
|
|
388
393
|
if route == "models":
|
|
389
394
|
return self.send_json(a.models(f))
|
|
390
395
|
if route == "projects":
|
package/finops/etl.py
CHANGED
|
@@ -16,7 +16,7 @@ from datetime import datetime, timezone
|
|
|
16
16
|
from .classify import classify
|
|
17
17
|
from .pricing import Pricing
|
|
18
18
|
|
|
19
|
-
from .paths import ROOT, DB_PATH
|
|
19
|
+
from .paths import ROOT, DB_PATH, DESKTOP_SESSIONS
|
|
20
20
|
DEFAULT_SOURCE = os.path.expanduser("~/.claude/projects")
|
|
21
21
|
|
|
22
22
|
SCHEMA_VERSION = 3 # 3: cross-session request_id dedup (resumed sessions copy history)
|
|
@@ -203,8 +203,14 @@ def _slug_to_name(slug):
|
|
|
203
203
|
|
|
204
204
|
|
|
205
205
|
class Loader:
|
|
206
|
-
def __init__(self, db_path=DB_PATH, source=DEFAULT_SOURCE, pricing=None, other_agents=True
|
|
206
|
+
def __init__(self, db_path=DB_PATH, source=DEFAULT_SOURCE, pricing=None, other_agents=True,
|
|
207
|
+
desktop_roots=None):
|
|
207
208
|
self.other_agents = other_agents
|
|
209
|
+
# An explicit source means "load exactly this"; only the default also picks up
|
|
210
|
+
# the desktop app's Cowork sessions (same JSONL format, different config dir).
|
|
211
|
+
self.desktop_roots = (desktop_roots if desktop_roots is not None
|
|
212
|
+
else [DESKTOP_SESSIONS] if source == DEFAULT_SOURCE else [])
|
|
213
|
+
self.cowork = False
|
|
208
214
|
self.db_path = db_path
|
|
209
215
|
self.source = source
|
|
210
216
|
self.pricing = pricing or Pricing()
|
|
@@ -232,13 +238,19 @@ class Loader:
|
|
|
232
238
|
if n.endswith(".jsonl"):
|
|
233
239
|
files.append(os.path.join(dirpath, n))
|
|
234
240
|
files.sort()
|
|
235
|
-
|
|
241
|
+
marker = os.sep + os.path.join(".claude", "projects") + os.sep
|
|
242
|
+
desktop = sorted(os.path.join(d, n) for root in self.desktop_roots
|
|
243
|
+
for d, _, names in os.walk(root) for n in names
|
|
244
|
+
if n.endswith(".jsonl") and marker in d + os.sep)
|
|
245
|
+
for i, fp in enumerate(files + desktop, 1):
|
|
236
246
|
if verbose and i % 20 == 0:
|
|
237
|
-
print(f" ...{i}/{len(files)} transcripts", file=sys.stderr)
|
|
247
|
+
print(f" ...{i}/{len(files) + len(desktop)} transcripts", file=sys.stderr)
|
|
248
|
+
self.cowork = i > len(files)
|
|
238
249
|
try:
|
|
239
250
|
self.load_file(fp)
|
|
240
251
|
except Exception as exc: # a corrupt transcript must not kill the load
|
|
241
252
|
print(f" ! skipped {os.path.basename(fp)}: {exc}", file=sys.stderr)
|
|
253
|
+
self.cowork = False
|
|
242
254
|
if self.other_agents:
|
|
243
255
|
from .agents import AgentLoader
|
|
244
256
|
counts = AgentLoader(self, log=lambda m: print(m, file=sys.stderr)).run()
|
|
@@ -253,6 +265,7 @@ class Loader:
|
|
|
253
265
|
for k, v in (
|
|
254
266
|
("source_dir", self.source),
|
|
255
267
|
("transcript_files", str(len(files))),
|
|
268
|
+
("desktop_transcript_files", str(len(desktop))),
|
|
256
269
|
("pricing_updated", str(self.pricing.updated)),
|
|
257
270
|
("pricing_source", str(self.pricing.source)),
|
|
258
271
|
("cost_basis", "estimated"),
|
|
@@ -260,7 +273,7 @@ class Loader:
|
|
|
260
273
|
):
|
|
261
274
|
self.db.execute("INSERT INTO meta VALUES (?,?)", (k, v))
|
|
262
275
|
self.db.commit()
|
|
263
|
-
return len(files)
|
|
276
|
+
return len(files) + len(desktop)
|
|
264
277
|
|
|
265
278
|
def project_id(self, slug, cwd):
|
|
266
279
|
if slug in self.projects:
|
|
@@ -271,6 +284,8 @@ class Loader:
|
|
|
271
284
|
return pid
|
|
272
285
|
is_sandbox = 1 if ("sandbox" in slug or slug.startswith("-private")) else 0
|
|
273
286
|
name = os.path.basename(cwd) if cwd else _slug_to_name(slug)
|
|
287
|
+
if self.cowork:
|
|
288
|
+
name = f"Cowork · {name or 'session'}"
|
|
274
289
|
cur = self.db.execute(
|
|
275
290
|
"INSERT INTO projects (slug, path, name, is_sandbox) VALUES (?,?,?,?)",
|
|
276
291
|
(slug, cwd, name or slug, is_sandbox))
|
package/finops/paths.py
CHANGED
|
@@ -109,3 +109,15 @@ def migrate(log=print):
|
|
|
109
109
|
with open(_MARKER, "w") as fh:
|
|
110
110
|
fh.write("state dir in use; delete this file to re-run migration\n")
|
|
111
111
|
return bool(moved)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# The Claude desktop app's data dir. Read-only: Cowork transcripts and the plan
|
|
115
|
+
# usage history live here.
|
|
116
|
+
if os.name == "nt":
|
|
117
|
+
DESKTOP_DIR = os.path.join(os.environ.get("APPDATA", ""), "Claude")
|
|
118
|
+
elif os.uname().sysname == "Darwin":
|
|
119
|
+
DESKTOP_DIR = os.path.join(os.path.expanduser("~"), "Library", "Application Support", "Claude")
|
|
120
|
+
else:
|
|
121
|
+
DESKTOP_DIR = os.path.join(os.path.expanduser("~"), ".config", "Claude")
|
|
122
|
+
DESKTOP_SESSIONS = os.path.join(DESKTOP_DIR, "local-agent-mode-sessions")
|
|
123
|
+
PLAN_HISTORY_PATH = os.path.join(DESKTOP_DIR, "plan-usage-history.json")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Plan-limit history, as the Claude desktop app records it.
|
|
2
|
+
|
|
3
|
+
The desktop app samples plan usage every few minutes into plan-usage-history.json:
|
|
4
|
+
`fh` is the 5-hour window and `sd` the weekly (seven-day) window, both percent used.
|
|
5
|
+
The format is undocumented, so anything unexpected returns {"ok": False, "reason"}
|
|
6
|
+
instead of raising. Read on request, cached on the file's mtime.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
|
|
12
|
+
from .paths import PLAN_HISTORY_PATH
|
|
13
|
+
|
|
14
|
+
KNOWN_VERSIONS = {2}
|
|
15
|
+
_cache = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _num(v):
|
|
19
|
+
return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _runs(values, threshold):
|
|
23
|
+
"""Separate stretches at or above the threshold, not raw sample counts."""
|
|
24
|
+
n, above = 0, False
|
|
25
|
+
for v in values:
|
|
26
|
+
hit = v is not None and v >= threshold
|
|
27
|
+
n += hit and not above
|
|
28
|
+
above = hit
|
|
29
|
+
return n
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _peak(values):
|
|
33
|
+
return max((v for v in values if v is not None), default=None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def history(path=None):
|
|
37
|
+
path = path or PLAN_HISTORY_PATH
|
|
38
|
+
try:
|
|
39
|
+
mtime = os.path.getmtime(path)
|
|
40
|
+
except OSError:
|
|
41
|
+
return {"ok": False, "reason": "No plan history on this machine. The Claude desktop "
|
|
42
|
+
"app records it; it is not installed or has not saved any yet."}
|
|
43
|
+
hit = _cache.get(path)
|
|
44
|
+
if hit and hit[0] == mtime:
|
|
45
|
+
return hit[1]
|
|
46
|
+
res = _read(path)
|
|
47
|
+
_cache[path] = (mtime, res)
|
|
48
|
+
return res
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _read(path):
|
|
52
|
+
try:
|
|
53
|
+
with open(path) as fh:
|
|
54
|
+
raw = json.load(fh)
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
return {"ok": False, "reason": "The plan history file could not be read."}
|
|
57
|
+
version = raw.get("version") if isinstance(raw, dict) else None
|
|
58
|
+
if version not in KNOWN_VERSIONS:
|
|
59
|
+
return {"ok": False, "reason": f"Unrecognised plan history format (version {version})."}
|
|
60
|
+
samples = [x for x in raw.get("samples") or []
|
|
61
|
+
if isinstance(x, dict) and _num(x.get("t")) is not None]
|
|
62
|
+
if not samples:
|
|
63
|
+
return {"ok": False, "reason": "The plan history file has no samples yet."}
|
|
64
|
+
samples.sort(key=lambda x: x["t"])
|
|
65
|
+
orgs = {x.get("org") for x in samples}
|
|
66
|
+
org = samples[-1].get("org")
|
|
67
|
+
series = []
|
|
68
|
+
for x in samples:
|
|
69
|
+
if x.get("org") != org:
|
|
70
|
+
continue
|
|
71
|
+
u = x.get("u") if isinstance(x.get("u"), dict) else {}
|
|
72
|
+
t = datetime.fromtimestamp(x["t"] / 1000, timezone.utc).isoformat().replace("+00:00", "Z")
|
|
73
|
+
series.append({"t": t, "five_hour": _num(u.get("fh")), "weekly": _num(u.get("sd"))})
|
|
74
|
+
fh = [r["five_hour"] for r in series]
|
|
75
|
+
sd = [r["weekly"] for r in series]
|
|
76
|
+
return {"ok": True, "source": path, "org_count": len(orgs), "series": series,
|
|
77
|
+
"summary": {"five_hour_peak": _peak(fh), "weekly_peak": _peak(sd),
|
|
78
|
+
"five_hour_ge90": _runs(fh, 90), "five_hour_hit100": _runs(fh, 100),
|
|
79
|
+
"weekly_ge90": _runs(sd, 90), "weekly_hit100": _runs(sd, 100),
|
|
80
|
+
"first": series[0]["t"], "last": series[-1]["t"]}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude-finops": "bin/claude-finops.js"
|
package/web/app.js
CHANGED
|
@@ -73,6 +73,13 @@ const dur = s => s == null ? '—' : s < 60 ? `${Math.round(s)}s`
|
|
|
73
73
|
const shortDay = d => (d || '').slice(5);
|
|
74
74
|
const shortId = s => (s || '').slice(0, 8);
|
|
75
75
|
|
|
76
|
+
// "(▲12% vs yesterday)"; nothing when yesterday had no spend to compare against.
|
|
77
|
+
const vsYesterday = o => {
|
|
78
|
+
const y = o.cost_yesterday?.c, t = o.cost_today?.c;
|
|
79
|
+
if (!y || t == null) return '';
|
|
80
|
+
const d = (t - y) / y * 100;
|
|
81
|
+
return ` (${d >= 0 ? '▲' : '▼'}${Math.abs(d).toFixed(0)}% vs yesterday)`;
|
|
82
|
+
};
|
|
76
83
|
function kpi(label, value, detail, opts = {}) {
|
|
77
84
|
const na = value == null;
|
|
78
85
|
return `<div class="kpi${na ? ' na' : ''}">
|
|
@@ -452,6 +459,8 @@ async function openSession(id) {
|
|
|
452
459
|
: '<div class="na">None</div>')}
|
|
453
460
|
</div>
|
|
454
461
|
${card('Session metadata', `<dl class="kv">
|
|
462
|
+
${s.resume ? `<dt>Resume</dt><dd><span class="mono">${esc(s.resume)}</span>
|
|
463
|
+
<button class="btn pb-copy" id="sess-resume" data-copy="${esc(s.resume)}">Copy</button></dd>` : ''}
|
|
455
464
|
<dt>Session ID</dt><dd class="mono">${esc(s.id)}</dd>
|
|
456
465
|
<dt>Title</dt><dd>${s.title ? esc(s.title) : NA()}</dd>
|
|
457
466
|
<dt>Project</dt><dd>${esc(s.project)}</dd>
|
|
@@ -463,6 +472,10 @@ async function openSession(id) {
|
|
|
463
472
|
<dt>Commits / PRs</dt><dd>${NA()}</dd>
|
|
464
473
|
</dl>`)}`;
|
|
465
474
|
wireTable(cont, s.prompts, r => openPrompt(r.prompt_id));
|
|
475
|
+
const rb = cont.querySelector('#sess-resume');
|
|
476
|
+
if (rb) rb.onclick = async () => {
|
|
477
|
+
try { await navigator.clipboard.writeText(rb.dataset.copy); rb.textContent = 'Copied'; }
|
|
478
|
+
catch { rb.textContent = 'Copy failed'; } };
|
|
466
479
|
const tl = s.timeline.map((r, i) => ({i, ...r}));
|
|
467
480
|
C.timeSeries($('#sesschart', cont), {rows: tl, x: 'i', type: 'area',
|
|
468
481
|
series: [{key: 'context_tokens', label: 'Context tokens', color: seriesVar(0)}],
|
|
@@ -500,7 +513,7 @@ VIEWS.overview = async (page) => {
|
|
|
500
513
|
page.innerHTML = `
|
|
501
514
|
<div class="grid g5">
|
|
502
515
|
${kpi('Estimated spend', fmtUSD(o.est_cost_usd),
|
|
503
|
-
`${fmtUSD(o.cost_today.c)} today · ${fmtUSD(o.cost_week.c)} last 7d`, {badge: BADGE.estimated})}
|
|
516
|
+
`${fmtUSD(o.cost_today.c)} today${vsYesterday(o)} · ${fmtUSD(o.cost_week.c)} last 7d`, {badge: BADGE.estimated})}
|
|
504
517
|
${kpi('Billable tokens', fmtNum(o.billable_tokens),
|
|
505
518
|
`${fmtNum(o.output_tokens)} output · ${fmtNum(o.cache_read_tokens)} cache read`,
|
|
506
519
|
{badge: BADGE.actual})}
|
|
@@ -763,9 +776,10 @@ const METRICS = [
|
|
|
763
776
|
['avg_context', 'Avg context', fmtNum],
|
|
764
777
|
];
|
|
765
778
|
VIEWS.usage = async (page) => {
|
|
766
|
-
const [tl, models, ov] = await Promise.all([api('timeline', `&grain=${S.grain}`),
|
|
767
|
-
api('models'), api('overview')]);
|
|
779
|
+
const [tl, models, ov, hm] = await Promise.all([api('timeline', `&grain=${S.grain}`),
|
|
780
|
+
api('models'), api('overview'), api('heatmap')]);
|
|
768
781
|
const [, mlabel, mfmt] = METRICS.find(m => m[0] === S.metric) || METRICS[0];
|
|
782
|
+
const hmMetric = ['cost', 'tokens', 'requests'].includes(S.metric) ? S.metric : 'cost';
|
|
769
783
|
page.innerHTML = `
|
|
770
784
|
<div class="grid g5">
|
|
771
785
|
${kpi('Total tokens', fmtNum(ov.billable_tokens), null, {badge: BADGE.actual})}
|
|
@@ -793,6 +807,9 @@ VIEWS.usage = async (page) => {
|
|
|
793
807
|
footer: 'Days are bucketed in UTC, as Claude Code timestamps its transcripts. '
|
|
794
808
|
+ 'If you work late in a timezone ahead of UTC, that work lands on the previous day here.',
|
|
795
809
|
flush: 0})}
|
|
810
|
+
${card('Peak hours', '<div class="chart" id="heat"></div>',
|
|
811
|
+
{badge: hmMetric === 'cost' ? BADGE.estimated : BADGE.actual,
|
|
812
|
+
hint: `your local time (${hm.tz || 'local'}) · ${hmMetric === 'cost' ? 'estimated cost' : hmMetric}`})}
|
|
796
813
|
${card('Model mix over time', '<div class="legend" id="mixleg"></div>' +
|
|
797
814
|
'<div class="chart" id="mix"></div>', {badge: BADGE.estimated,
|
|
798
815
|
hint: 'stacked estimated cost per model'})}
|
|
@@ -810,6 +827,7 @@ VIEWS.usage = async (page) => {
|
|
|
810
827
|
xLabel: v => S.grain === 'day' ? shortDay(v) : v.slice(8).replace('T', ' ') + ':00',
|
|
811
828
|
onClick: r => { if (S.grain === 'day') { S.filter.start = r.bucket; S.filter.end = r.bucket;
|
|
812
829
|
S.range = 'custom'; bust(); render(); } }});
|
|
830
|
+
C.heatmap($('#heat', page), {cells: hm.cells, metric: hmMetric});
|
|
813
831
|
|
|
814
832
|
// per-model stacked mix
|
|
815
833
|
const byDay = new Map();
|
|
@@ -849,7 +867,8 @@ VIEWS.usage = async (page) => {
|
|
|
849
867
|
|
|
850
868
|
/* ---------- burn & limits ---------- */
|
|
851
869
|
VIEWS.burn = async (page) => {
|
|
852
|
-
const burn = await api('burn')
|
|
870
|
+
const [burn, plan] = await Promise.all([api('burn'), fetch('/api/plan_history')
|
|
871
|
+
.then(r => r.json()).catch(e => ({ok: false, reason: e.message}))]);
|
|
853
872
|
const bp = burn.period;
|
|
854
873
|
const rows = Object.entries(burn.allowances);
|
|
855
874
|
page.innerHTML = `
|
|
@@ -901,10 +920,31 @@ VIEWS.burn = async (page) => {
|
|
|
901
920
|
<span class="mono">config/settings.json → limits</span> to enable usage-vs-limit,
|
|
902
921
|
days-until-limit and limit-date projections.</p></div>`,
|
|
903
922
|
{badge: a.configured ? BADGE.estimated : ''})).join('')}</div>
|
|
923
|
+
${card('Plan limits over time', plan.ok ? `
|
|
924
|
+
<div class="grid g3">
|
|
925
|
+
${kpi('5-hour peak', fmtPct(plan.summary.five_hour_peak ?? 0, 0), null, {badge: BADGE.actual})}
|
|
926
|
+
${kpi('Times at 90%+ (5-hour)', fmtInt(plan.summary.five_hour_ge90),
|
|
927
|
+
`${fmtInt(plan.summary.five_hour_hit100)} reached 100%`)}
|
|
928
|
+
${kpi('Weekly peak', fmtPct(plan.summary.weekly_peak ?? 0, 0),
|
|
929
|
+
`${fmtInt(plan.summary.weekly_ge90)} times at 90%+`)}
|
|
930
|
+
</div>
|
|
931
|
+
<div class="legend" id="planleg"></div><div class="chart" id="plan"></div>`
|
|
932
|
+
: `<div class="empty">${esc(plan.reason || 'Unavailable')}</div>`,
|
|
933
|
+
{badge: BADGE.actual, hint: '5-hour and weekly plan usage, % used',
|
|
934
|
+
footer: 'Read from the Claude desktop app\'s local plan-usage-history.json. '
|
|
935
|
+
+ 'Its format is undocumented, so this card may go blank after an app update.'})}
|
|
904
936
|
${card('Daily consumption within the billing period', '<div class="chart" id="bs"></div>',
|
|
905
937
|
{badge: BADGE.estimated})}`;
|
|
906
938
|
for (const [k, a] of rows) if (a.configured)
|
|
907
939
|
C.gauge($(`#g-${k}`, page), {pct: a.used_pct, status: a.status, label: 'of allowance'});
|
|
940
|
+
if (plan.ok) {
|
|
941
|
+
const ps = [{key: 'five_hour', label: '5-hour window', color: seriesVar(0), fmt: v => fmtPct(v, 0)},
|
|
942
|
+
{key: 'weekly', label: 'Weekly window', color: seriesVar(1), fmt: v => fmtPct(v, 0)}];
|
|
943
|
+
C.legend($('#planleg', page), ps.map(s => ({label: s.label, color: s.color})));
|
|
944
|
+
C.timeSeries($('#plan', page), {rows: plan.series, x: 't', type: 'line', series: ps,
|
|
945
|
+
fmt: v => fmtPct(v, 0), max: 100, height: 220,
|
|
946
|
+
xLabel: t => new Date(t).toLocaleString([], {month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'})});
|
|
947
|
+
}
|
|
908
948
|
C.timeSeries($('#bs', page), {rows: burn.series, x: 'day', type: 'bar',
|
|
909
949
|
series: [{key: 'cost', label: 'Estimated cost', color: seriesVar(0), fmt: fmtUSD}],
|
|
910
950
|
fmt: fmtUSD, height: 220, xLabel: shortDay});
|
package/web/charts.js
CHANGED
|
@@ -70,7 +70,7 @@ export function timeSeries(host, opts) {
|
|
|
70
70
|
const totals = rows.map(r => stacked
|
|
71
71
|
? active.reduce((a, s) => a + (+r[s.key] || 0), 0)
|
|
72
72
|
: Math.max(...active.map(s => +r[s.key] || 0), 0));
|
|
73
|
-
const max = nice(Math.max(...totals, 0) || 1);
|
|
73
|
+
const max = Math.max(opts.max || 0, nice(Math.max(...totals, 0) || 1));
|
|
74
74
|
const y = v => m.t + ih - (v / max) * ih;
|
|
75
75
|
const n = rows.length;
|
|
76
76
|
const bw = Math.max(2, Math.min(34, iw / n * 0.72));
|
|
@@ -349,3 +349,36 @@ export function legend(host, items, onToggle) {
|
|
|
349
349
|
host.appendChild(s);
|
|
350
350
|
});
|
|
351
351
|
}
|
|
352
|
+
|
|
353
|
+
/* ---------- heatmap: weekday x hour ---------- */
|
|
354
|
+
const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
355
|
+
export function heatmap(host, {cells, metric = 'cost'}) {
|
|
356
|
+
host.innerHTML = '';
|
|
357
|
+
const max = Math.max(0, ...cells.map(c => +c[metric] || 0));
|
|
358
|
+
if (!max) { host.innerHTML = '<div class="empty">No activity in range</div>'; return; }
|
|
359
|
+
const W = Math.max(host.clientWidth || 640, 320);
|
|
360
|
+
const m = {t: 4, r: 4, b: 18, l: 34};
|
|
361
|
+
const cw = (W - m.l - m.r) / 24, ch = Math.min(22, Math.max(12, cw * 0.8));
|
|
362
|
+
const H = m.t + ch * 7 + m.b;
|
|
363
|
+
const svg = el('svg', {viewBox: `0 0 ${W} ${H}`, height: H, role: 'img'});
|
|
364
|
+
DOW.forEach((d, i) => svg.appendChild(el('text', {x: m.l - 6, y: m.t + i * ch + ch * 0.68,
|
|
365
|
+
'text-anchor': 'end'}, d)));
|
|
366
|
+
for (let h = 0; h < 24; h += 3) svg.appendChild(el('text', {x: m.l + h * cw + cw / 2,
|
|
367
|
+
y: H - 5, 'text-anchor': 'middle'}, String(h).padStart(2, '0')));
|
|
368
|
+
cells.forEach(c => {
|
|
369
|
+
const v = +c[metric] || 0;
|
|
370
|
+
const r = el('rect', {x: m.l + c.hour * cw + 1, y: m.t + c.dow * ch + 1,
|
|
371
|
+
width: Math.max(1, cw - 2), height: Math.max(1, ch - 2), rx: 2,
|
|
372
|
+
fill: v ? 'var(--s1)' : 'var(--grid)',
|
|
373
|
+
'fill-opacity': v ? (0.15 + 0.85 * Math.sqrt(v / max)).toFixed(3) : 1});
|
|
374
|
+
r.addEventListener('mousemove', ev => showTip(
|
|
375
|
+
`<div class="t">${DOW[c.dow]} ${String(c.hour).padStart(2, '0')}:00</div>
|
|
376
|
+
<div class="row"><span class="k">Est. cost</span><span class="v">${fmtUSD(c.cost)}</span></div>
|
|
377
|
+
<div class="row"><span class="k">Tokens</span><span class="v">${fmtNum(c.tokens)}</span></div>
|
|
378
|
+
<div class="row"><span class="k">Requests</span><span class="v">${fmtInt(c.requests)}</span></div>`,
|
|
379
|
+
ev.clientX, ev.clientY));
|
|
380
|
+
r.addEventListener('mouseleave', hideTip);
|
|
381
|
+
svg.appendChild(r);
|
|
382
|
+
});
|
|
383
|
+
host.appendChild(svg);
|
|
384
|
+
}
|