claude-finops 0.16.0 → 0.17.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 +3 -21
- package/config/pricing.json +1 -141
- package/finops/actions.py +11 -18
- package/finops/analytics.py +31 -62
- package/finops/api.py +9 -19
- package/finops/cloud.py +10 -107
- package/finops/diagnose.py +12 -85
- package/finops/etl.py +6 -10
- package/finops/procs.py +1 -189
- package/finops/segments.py +2 -2
- package/package.json +2 -5
- package/web/app.js +170 -223
- package/finops/agents.py +0 -271
package/README.md
CHANGED
|
@@ -230,7 +230,7 @@ without it; it only adds the warning and "continue?" prompt inside Claude Code.
|
|
|
230
230
|
**Settings** (sidebar, Setup) stores the optional API keys used by **Billed vs local**, in
|
|
231
231
|
`~/.claude-finops/secrets.local.json` (0600, the same file `--set-key` writes). The page only
|
|
232
232
|
ever gets back whether a key is set, where from, and its last four characters. An environment
|
|
233
|
-
variable (`ANTHROPIC_ADMIN_KEY
|
|
233
|
+
variable (`ANTHROPIC_ADMIN_KEY`) takes priority over a stored key.
|
|
234
234
|
|
|
235
235
|
### Per-session token budget and the session guard
|
|
236
236
|
|
|
@@ -374,8 +374,7 @@ place for you to delete once you are happy.
|
|
|
374
374
|
**What it reads, all read-only:** Claude Code transcripts in `~/.claude/projects`, and,
|
|
375
375
|
if the Claude desktop app is installed, its Cowork transcripts and
|
|
376
376
|
`plan-usage-history.json` (`~/Library/Application Support/Claude` on macOS,
|
|
377
|
-
`%APPDATA%\Claude` on Windows, `~/.config/Claude` on Linux)
|
|
378
|
-
agents listed under Multi-agent.
|
|
377
|
+
`%APPDATA%\Claude` on Windows, `~/.config/Claude` on Linux).
|
|
379
378
|
|
|
380
379
|
`~/.claude-finops/data/finops.db` and the prompt/CSV exports contain **your full prompt text**. The
|
|
381
380
|
server binds to `127.0.0.1` only, but treat the database and any export you
|
|
@@ -452,7 +451,7 @@ files on Windows.
|
|
|
452
451
|
- **Recent** (clock icon in the top bar, or press `R`) opens a side panel on any page: running sessions first,
|
|
453
452
|
with Resume, Compact and Stop (Interrupt), then past sessions newest first. Click a session to
|
|
454
453
|
list its prompts, and click a prompt for its full details. Search matches your prompt text.
|
|
455
|
-
It covers all dates but keeps the
|
|
454
|
+
It covers all dates but keeps the project filters. Close and Force kill stay on
|
|
456
455
|
Running sessions.
|
|
457
456
|
- **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal
|
|
458
457
|
(in herdr, Interrupt presses Esc in the session's pane instead). Each asks you to click twice. Interrupt is macOS/Linux only: on Windows there is no way to
|
|
@@ -508,23 +507,6 @@ The **Jev (fast decisions)** page (sidebar, Optimize):
|
|
|
508
507
|
tools, worded as "which one", "yes or no", "classify"…) and what they would cost at TypeSafe's
|
|
509
508
|
published price ($0.042 per million input tokens). Read-only.
|
|
510
509
|
|
|
511
|
-
---
|
|
512
|
-
|
|
513
|
-
## Multi-agent
|
|
514
|
-
|
|
515
|
-
Besides Claude Code, the warehouse loads every other coding agent it finds on the machine:
|
|
516
|
-
|
|
517
|
-
| Agent | Read from | What it gives |
|
|
518
|
-
|---|---|---|
|
|
519
|
-
| Codex | `~/.codex/sessions` | Tokens + model per turn; cost at OpenAI list price |
|
|
520
|
-
| Gemini CLI | `~/.gemini/tmp/*/chats` | Tokens + model per reply; cost at Gemini list price |
|
|
521
|
-
| Cursor | Cursor IDE `state.vscdb` (read-only) | Prompts, times and tokens as the IDE stored them; no model, not priced. Agent transcripts are skipped: they carry no times or tokens |
|
|
522
|
-
|
|
523
|
-
Pick agents with the chips at the top: click for one, Cmd/Ctrl-click to combine, **All** for
|
|
524
|
-
everything. Every page follows the selection, and **Agents** shows them side by side.
|
|
525
|
-
Prices for the other providers live in `config/pricing.json` with their source URLs.
|
|
526
|
-
|
|
527
|
-
|
|
528
510
|
---
|
|
529
511
|
|
|
530
512
|
## License and trademarks
|
package/config/pricing.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"currency": "USD",
|
|
4
4
|
"unit": "per_million_tokens",
|
|
5
5
|
"updated": "2026-10-02",
|
|
6
|
-
"source": "Anthropic list prices (platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-28; Sonnet 5.5 added 2026-10-02)
|
|
6
|
+
"source": "Anthropic list prices (platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-28; Sonnet 5.5 added 2026-10-02)",
|
|
7
7
|
"models": {
|
|
8
8
|
"claude-opus-5": {
|
|
9
9
|
"display_name": "Claude Opus 5",
|
|
@@ -237,149 +237,9 @@
|
|
|
237
237
|
"cache_write_1h": 0.0,
|
|
238
238
|
"cache_read": 0.0,
|
|
239
239
|
"context_window": 0
|
|
240
|
-
},
|
|
241
|
-
"gpt-5.5": {
|
|
242
|
-
"display_name": "GPT-5.5 (Codex)",
|
|
243
|
-
"tier": "frontier",
|
|
244
|
-
"provider": "openai",
|
|
245
|
-
"input": 5.0,
|
|
246
|
-
"output": 30.0,
|
|
247
|
-
"cache_read": 0.5,
|
|
248
|
-
"cache_write_5m": 5.0,
|
|
249
|
-
"cache_write_1h": 5.0,
|
|
250
|
-
"context_window": 400000
|
|
251
|
-
},
|
|
252
|
-
"gpt-5.3-codex": {
|
|
253
|
-
"display_name": "GPT-5.3 Codex",
|
|
254
|
-
"tier": "balanced",
|
|
255
|
-
"provider": "openai",
|
|
256
|
-
"input": 1.75,
|
|
257
|
-
"output": 14.0,
|
|
258
|
-
"cache_read": 0.175,
|
|
259
|
-
"cache_write_5m": 1.75,
|
|
260
|
-
"cache_write_1h": 1.75,
|
|
261
|
-
"context_window": 400000
|
|
262
|
-
},
|
|
263
|
-
"gpt-5.2-codex": {
|
|
264
|
-
"display_name": "GPT-5.2 Codex",
|
|
265
|
-
"tier": "balanced",
|
|
266
|
-
"provider": "openai",
|
|
267
|
-
"input": 1.75,
|
|
268
|
-
"output": 14.0,
|
|
269
|
-
"cache_read": 0.175,
|
|
270
|
-
"cache_write_5m": 1.75,
|
|
271
|
-
"cache_write_1h": 1.75,
|
|
272
|
-
"context_window": 400000,
|
|
273
|
-
"_note": "Not on OpenAI's price list; priced as GPT-5.2"
|
|
274
|
-
},
|
|
275
|
-
"gpt-5.2": {
|
|
276
|
-
"display_name": "GPT-5.2",
|
|
277
|
-
"tier": "balanced",
|
|
278
|
-
"provider": "openai",
|
|
279
|
-
"input": 1.75,
|
|
280
|
-
"output": 14.0,
|
|
281
|
-
"cache_read": 0.175,
|
|
282
|
-
"cache_write_5m": 1.75,
|
|
283
|
-
"cache_write_1h": 1.75,
|
|
284
|
-
"context_window": 400000
|
|
285
|
-
},
|
|
286
|
-
"gpt-5.1": {
|
|
287
|
-
"display_name": "GPT-5.1",
|
|
288
|
-
"tier": "balanced",
|
|
289
|
-
"provider": "openai",
|
|
290
|
-
"input": 1.25,
|
|
291
|
-
"output": 10.0,
|
|
292
|
-
"cache_read": 0.125,
|
|
293
|
-
"cache_write_5m": 1.25,
|
|
294
|
-
"cache_write_1h": 1.25,
|
|
295
|
-
"context_window": 400000
|
|
296
|
-
},
|
|
297
|
-
"gpt-5": {
|
|
298
|
-
"display_name": "GPT-5",
|
|
299
|
-
"tier": "balanced",
|
|
300
|
-
"provider": "openai",
|
|
301
|
-
"input": 1.25,
|
|
302
|
-
"output": 10.0,
|
|
303
|
-
"cache_read": 0.125,
|
|
304
|
-
"cache_write_5m": 1.25,
|
|
305
|
-
"cache_write_1h": 1.25,
|
|
306
|
-
"context_window": 400000
|
|
307
|
-
},
|
|
308
|
-
"gpt-5-mini": {
|
|
309
|
-
"display_name": "GPT-5 mini",
|
|
310
|
-
"tier": "economy",
|
|
311
|
-
"provider": "openai",
|
|
312
|
-
"input": 0.25,
|
|
313
|
-
"output": 2.0,
|
|
314
|
-
"cache_read": 0.025,
|
|
315
|
-
"cache_write_5m": 0.25,
|
|
316
|
-
"cache_write_1h": 0.25,
|
|
317
|
-
"context_window": 400000
|
|
318
|
-
},
|
|
319
|
-
"gemini-2.5-flash": {
|
|
320
|
-
"display_name": "Gemini 2.5 Flash",
|
|
321
|
-
"tier": "economy",
|
|
322
|
-
"provider": "google",
|
|
323
|
-
"input": 0.3,
|
|
324
|
-
"output": 2.5,
|
|
325
|
-
"cache_read": 0.03,
|
|
326
|
-
"cache_write_5m": 0.3,
|
|
327
|
-
"cache_write_1h": 0.3,
|
|
328
|
-
"context_window": 1048576,
|
|
329
|
-
"_note": "Cache read: lower bound of Google's published range"
|
|
330
|
-
},
|
|
331
|
-
"gemini-2.5-pro": {
|
|
332
|
-
"display_name": "Gemini 2.5 Pro",
|
|
333
|
-
"tier": "frontier",
|
|
334
|
-
"provider": "google",
|
|
335
|
-
"input": 1.25,
|
|
336
|
-
"output": 10.0,
|
|
337
|
-
"cache_read": 0.125,
|
|
338
|
-
"cache_write_5m": 1.25,
|
|
339
|
-
"cache_write_1h": 1.25,
|
|
340
|
-
"context_window": 1048576,
|
|
341
|
-
"_note": "<=200K prompt; $2.50/$15 above"
|
|
342
|
-
},
|
|
343
|
-
"gemini-3.5-flash": {
|
|
344
|
-
"display_name": "Gemini 3.5 Flash",
|
|
345
|
-
"tier": "balanced",
|
|
346
|
-
"provider": "google",
|
|
347
|
-
"input": 1.5,
|
|
348
|
-
"output": 9.0,
|
|
349
|
-
"cache_read": 0.15,
|
|
350
|
-
"cache_write_5m": 1.5,
|
|
351
|
-
"cache_write_1h": 1.5,
|
|
352
|
-
"context_window": 1048576
|
|
353
|
-
},
|
|
354
|
-
"gemini-3.8-flash": {
|
|
355
|
-
"display_name": "Gemini 3.8 Flash",
|
|
356
|
-
"tier": "economy",
|
|
357
|
-
"provider": "google",
|
|
358
|
-
"input": 0.75,
|
|
359
|
-
"output": 3.75,
|
|
360
|
-
"cache_read": 0.075,
|
|
361
|
-
"cache_write_5m": 0.75,
|
|
362
|
-
"cache_write_1h": 0.75,
|
|
363
|
-
"context_window": 1048576,
|
|
364
|
-
"_note": "Promo price through 2026-12-31; $1.50/$7.50 after"
|
|
365
|
-
},
|
|
366
|
-
"cursor": {
|
|
367
|
-
"display_name": "Cursor (model not recorded)",
|
|
368
|
-
"tier": "none",
|
|
369
|
-
"provider": "cursor",
|
|
370
|
-
"input": 0,
|
|
371
|
-
"output": 0,
|
|
372
|
-
"cache_read": 0,
|
|
373
|
-
"cache_write_5m": 0,
|
|
374
|
-
"cache_write_1h": 0,
|
|
375
|
-
"_note": "Cursor bills by subscription and doesn't record the model locally: no cost is estimated"
|
|
376
240
|
}
|
|
377
241
|
},
|
|
378
242
|
"aliases": {
|
|
379
243
|
"claude-haiku-4-5": "claude-haiku-4-5-20251001"
|
|
380
|
-
},
|
|
381
|
-
"_sources": {
|
|
382
|
-
"openai": "https://developers.openai.com/api/docs/pricing (standard tier, 2026-09-18)",
|
|
383
|
-
"google": "https://ai.google.dev/gemini-api/docs/pricing (paid tier, 2026-09-18)"
|
|
384
244
|
}
|
|
385
245
|
}
|
package/finops/actions.py
CHANGED
|
@@ -440,9 +440,7 @@ def _slug(s):
|
|
|
440
440
|
def suggestions(a, f=None):
|
|
441
441
|
"""Skills and MCP servers worth adding, from recurring work (actual transcripts).
|
|
442
442
|
|
|
443
|
-
|
|
444
|
-
MCP servers; another agent's templated prompts would suggest things Claude never
|
|
445
|
-
repeats), within the date range when one is given. Scanning is cached per range on
|
|
443
|
+
Claude Code's own prompts and commands count, within the date range when one is given. Scanning is cached per range on
|
|
446
444
|
the warehouse (reset by sync); installed-state is re-checked on every call.
|
|
447
445
|
"""
|
|
448
446
|
f = f or {}
|
|
@@ -648,36 +646,32 @@ def free_model_test(log, mid):
|
|
|
648
646
|
return {"ok": True, "command": m["command"]}
|
|
649
647
|
|
|
650
648
|
|
|
651
|
-
def compare(a,
|
|
652
|
-
"""
|
|
653
|
-
|
|
649
|
+
def compare(a, f=None):
|
|
650
|
+
"""Claude models (with your usage) side by side with the free models that plug into
|
|
651
|
+
Claude Code."""
|
|
654
652
|
with open(os.path.join(ROOT, "config", "model_compare.json")) as fh:
|
|
655
653
|
ratings = json.load(fh)["ratings"]
|
|
656
|
-
|
|
657
|
-
prov = {"claude": "anthropic", "codex": "openai", "gemini": "google"}
|
|
658
|
-
want = {prov[x] for x in agents if x in prov}
|
|
659
|
-
where = {"anthropic": "Anthropic cloud", "openai": "OpenAI cloud", "google": "Google cloud"}
|
|
660
|
-
w, wp = a.where(dict(f or {}, agents=agents))
|
|
654
|
+
w, wp = a.where(f or {})
|
|
661
655
|
used = {r["model"]: r for r in a.q(f"""SELECT r.model, COUNT(*) requests, SUM(r.est_cost_usd) cost,
|
|
662
656
|
SUM(r.billable_tokens) tokens FROM requests r
|
|
663
657
|
WHERE {w} GROUP BY r.model""", wp)}
|
|
664
658
|
rows = []
|
|
665
659
|
for mid, p in a.pricing.models.items():
|
|
666
|
-
|
|
667
|
-
|
|
660
|
+
if p.get("tier") in (None, "none", "other") or p.get("provider", "anthropic") != "anthropic" \
|
|
661
|
+
or mid.endswith("[1m]"):
|
|
668
662
|
continue
|
|
669
663
|
u = used.get(mid) or {}
|
|
670
664
|
# An unrated Claude model still belongs in the table when you used it; unused
|
|
671
665
|
# unrated ones (old ids kept for pricing history) would only add noise.
|
|
672
|
-
if
|
|
666
|
+
if mid not in ratings and not u:
|
|
673
667
|
continue
|
|
674
668
|
rt = ratings.get(mid) or {"tools": None, "reasoning": None, "multifile": None,
|
|
675
669
|
"speed": "—", "best_for": p.get("tier", "").capitalize() + " tier"}
|
|
676
|
-
rows.append({"id": mid, "name": p.get("display_name", mid), "kind": "claude"
|
|
677
|
-
"where":
|
|
670
|
+
rows.append({"id": mid, "name": p.get("display_name", mid), "kind": "claude",
|
|
671
|
+
"where": "Anthropic cloud", "price_in": p.get("input"), "price_out": p.get("output"),
|
|
678
672
|
"context": p.get("context_window"), "requests": u.get("requests") or 0,
|
|
679
673
|
"cost": u.get("cost") or 0, "tokens": u.get("tokens") or 0, "tier": p.get("tier"), **rt})
|
|
680
|
-
fm = free_models()
|
|
674
|
+
fm = free_models()
|
|
681
675
|
for m in fm["models"]:
|
|
682
676
|
if m["id"] not in ratings:
|
|
683
677
|
continue
|
|
@@ -691,7 +685,6 @@ def compare(a, agents=None, f=None):
|
|
|
691
685
|
-rank.get(r.get("tier"), 3))
|
|
692
686
|
rows.sort(key=lambda r: score(r), reverse=True)
|
|
693
687
|
return {"rows": rows, "os": fm["os"], "ram_gb": fm["models"][0]["ram_gb"] if fm["models"] else None,
|
|
694
|
-
"agents": agents,
|
|
695
688
|
"note": "Ratings are judgement, not benchmarks; edit config/model_compare.json. "
|
|
696
689
|
"Models you used that have no rating show — until you add them there. Tool use matters most: coding "
|
|
697
690
|
"agents work by calling tools, and weak tool use means loops, broken edits "
|
package/finops/analytics.py
CHANGED
|
@@ -64,6 +64,21 @@ def _version():
|
|
|
64
64
|
return ""
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
def _served_from():
|
|
68
|
+
"""Which copy is serving: a git checkout (with its commit) or an npm install."""
|
|
69
|
+
from .paths import ROOT
|
|
70
|
+
out = {"kind": "npm", "path": ROOT, "commit": None}
|
|
71
|
+
if os.path.exists(os.path.join(ROOT, ".git")):
|
|
72
|
+
out["kind"] = "source"
|
|
73
|
+
try:
|
|
74
|
+
import subprocess
|
|
75
|
+
out["commit"] = subprocess.run(["git", "-C", ROOT, "rev-parse", "--short", "HEAD"],
|
|
76
|
+
capture_output=True, text=True, timeout=3).stdout.strip() or None
|
|
77
|
+
except (OSError, subprocess.SubprocessError):
|
|
78
|
+
pass
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
67
82
|
def detect_account():
|
|
68
83
|
"""Who Claude Code is signed in as, read from ~/.claude.json (actual, not guessed).
|
|
69
84
|
|
|
@@ -257,8 +272,6 @@ class Analytics:
|
|
|
257
272
|
cl.append("r.day >= ?"); p.append(f["start"])
|
|
258
273
|
if f.get("end"):
|
|
259
274
|
cl.append("r.day <= ?"); p.append(f["end"])
|
|
260
|
-
if f.get("agents"):
|
|
261
|
-
cl.append("r.agent IN (%s)" % ",".join("?" * len(f["agents"]))); p += f["agents"]
|
|
262
275
|
if f.get("models"):
|
|
263
276
|
cl.append("r.model IN (%s)" % ",".join("?" * len(f["models"]))); p += f["models"]
|
|
264
277
|
if f.get("projects"):
|
|
@@ -353,8 +366,8 @@ class Analytics:
|
|
|
353
366
|
p).get("n", 0)
|
|
354
367
|
tot["tool_calls"] = self.one(
|
|
355
368
|
f"SELECT COALESCE(SUM(r.tool_call_count),0) n FROM requests r WHERE {w}", p).get("n", 0)
|
|
356
|
-
# Requests with tokens but no price (
|
|
357
|
-
#
|
|
369
|
+
# Requests with tokens but no price (a model missing from pricing.json):
|
|
370
|
+
# they count, but at $0.
|
|
358
371
|
tot["unpriced_requests"] = self.one(
|
|
359
372
|
f"SELECT COUNT(*) n FROM requests r WHERE {w} AND r.est_cost_usd = 0 AND r.billable_tokens > 0",
|
|
360
373
|
p).get("n", 0)
|
|
@@ -683,18 +696,12 @@ class Analytics:
|
|
|
683
696
|
r["output_ratio"] = (r["output_tokens"] or 0) / r["tokens"] if r["tokens"] else 0
|
|
684
697
|
cr, cw = r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0
|
|
685
698
|
r["cache_hit_ratio"] = (cr / (cr + cw)) if (cr + cw) else None
|
|
686
|
-
r["budget_tokens"] = self.session_budget(r
|
|
699
|
+
r["budget_tokens"] = self.session_budget(r.pop("project_path"), r["session_id"])
|
|
687
700
|
r["resume"] = resume_command(r["session_id"], r.pop("agent"), r.pop("source_file"))
|
|
688
701
|
return rows
|
|
689
702
|
|
|
690
|
-
def session_budget(self,
|
|
691
|
-
"""The per-session token budget that applies to a session, or None.
|
|
692
|
-
|
|
693
|
-
Claude Code sessions only: the guard hook runs there, and other agents'
|
|
694
|
-
sessions are not what the budget was set for.
|
|
695
|
-
"""
|
|
696
|
-
if agent != "claude":
|
|
697
|
-
return None
|
|
703
|
+
def session_budget(self, project_path, session_id=None):
|
|
704
|
+
"""The per-session token budget that applies to a session, or None."""
|
|
698
705
|
from .guard import budget_for
|
|
699
706
|
return budget_for(self.settings.get("guard") or {}, project_path, session_id)
|
|
700
707
|
|
|
@@ -1228,8 +1235,7 @@ class Analytics:
|
|
|
1228
1235
|
ORDER BY cost DESC LIMIT 20""", p + [thr])
|
|
1229
1236
|
agg = self.one(f"SELECT AVG(r.context_tokens) a, MAX(r.context_tokens) m FROM requests r WHERE {w}", p)
|
|
1230
1237
|
# Utilisation against each request's own model window, weighted by requests —
|
|
1231
|
-
# not against the largest window anywhere in the price table
|
|
1232
|
-
# would set the scale for Claude-only usage).
|
|
1238
|
+
# not against the largest window anywhere in the price table.
|
|
1233
1239
|
num = den = 0.0
|
|
1234
1240
|
wins = defaultdict(int)
|
|
1235
1241
|
for v in self.q(f"""SELECT COALESCE(r.priced_as, r.model) m, COUNT(*) n, AVG(r.context_tokens) a
|
|
@@ -1792,8 +1798,8 @@ class Analytics:
|
|
|
1792
1798
|
out["lines"].append(self._session_budget_line(f, bp, line))
|
|
1793
1799
|
else:
|
|
1794
1800
|
out["lines"].append(line("Per-session tokens", None, 0, None, unit="tokens"))
|
|
1795
|
-
# Per-project and per-model lines
|
|
1796
|
-
wa, pa = self.where({
|
|
1801
|
+
# Per-project and per-model lines cover the whole budget period, not the date filter.
|
|
1802
|
+
wa, pa = self.where({})
|
|
1797
1803
|
for proj, bud in (b.get("per_project_usd") or {}).items():
|
|
1798
1804
|
act = self.one(f"""SELECT COALESCE(SUM(r.est_cost_usd),0) c FROM requests r
|
|
1799
1805
|
JOIN projects pr ON pr.id=r.project_id
|
|
@@ -1850,7 +1856,7 @@ class Analytics:
|
|
|
1850
1856
|
p + [bp["start"], bp["end"]])
|
|
1851
1857
|
scored = []
|
|
1852
1858
|
for r in rows:
|
|
1853
|
-
bud = self.session_budget(
|
|
1859
|
+
bud = self.session_budget(r.pop("path"), r["session_id"])
|
|
1854
1860
|
if bud:
|
|
1855
1861
|
r["budget"], r["pct"] = bud, round(100.0 * (r["tokens"] or 0) / bud, 1)
|
|
1856
1862
|
scored.append(r)
|
|
@@ -1877,8 +1883,8 @@ class Analytics:
|
|
|
1877
1883
|
series = [d for d in self.daily_series(f, end=last_full.isoformat())]
|
|
1878
1884
|
priced = [d for d in series if d["cost"] > 0]
|
|
1879
1885
|
if len(priced) >= 14:
|
|
1880
|
-
# Median/MAD, not mean/stdev: unpriced $0 days
|
|
1881
|
-
# data
|
|
1886
|
+
# Median/MAD, not mean/stdev: unpriced $0 days (models without pricing
|
|
1887
|
+
# data) would otherwise pollute the mean/sd baseline and
|
|
1882
1888
|
# either mask real spikes or manufacture fake ones. MAD is scaled by
|
|
1883
1889
|
# 1.4826 so it estimates the same thing a standard deviation would under
|
|
1884
1890
|
# a normal distribution, without a few extreme days inflating it the way
|
|
@@ -1905,8 +1911,8 @@ class Analytics:
|
|
|
1905
1911
|
# against — which understates how far out they really are. Candidates are
|
|
1906
1912
|
# ranked by tokens too, since that is the metric being tested; ordering by
|
|
1907
1913
|
# cost hid token-heavy work on cheap models.
|
|
1908
|
-
# Sessions with no token data
|
|
1909
|
-
#
|
|
1914
|
+
# Sessions with no token data are not comparable and would drag the
|
|
1915
|
+
# baseline down.
|
|
1910
1916
|
vals = [s["tokens"] for s in sess if (s["tokens"] or 0) > 0]
|
|
1911
1917
|
mean = (statistics.median(vals) if vals else 0) or 1
|
|
1912
1918
|
# Session sizes are heavy-tailed enough that any fixed multiple of the
|
|
@@ -2142,48 +2148,10 @@ class Analytics:
|
|
|
2142
2148
|
}
|
|
2143
2149
|
|
|
2144
2150
|
# ---------------- filter option lists ----------------
|
|
2145
|
-
def agents(self):
|
|
2146
|
-
"""Every agent detected on this machine, with what its local data can show."""
|
|
2147
|
-
from .agents import AGENTS, detect
|
|
2148
|
-
found = detect()
|
|
2149
|
-
used = {r["agent"]: r for r in self.q("""SELECT agent, COUNT(*) requests,
|
|
2150
|
-
COUNT(DISTINCT session_id) sessions, SUM(billable_tokens) tokens,
|
|
2151
|
-
SUM(est_cost_usd) cost, MIN(day) first, MAX(day) last FROM requests GROUP BY agent""")}
|
|
2152
|
-
out = []
|
|
2153
|
-
for k, a in AGENTS.items():
|
|
2154
|
-
u = used.get(k) or {}
|
|
2155
|
-
if not found.get(k) and not u:
|
|
2156
|
-
continue
|
|
2157
|
-
out.append({"id": k, "name": a["name"], "data": a["data"], "note": a["note"],
|
|
2158
|
-
"requests": u.get("requests") or 0, "sessions": u.get("sessions") or 0,
|
|
2159
|
-
"tokens": u.get("tokens") or 0, "cost": u.get("cost") or 0,
|
|
2160
|
-
"first": u.get("first"), "last": u.get("last")})
|
|
2161
|
-
return out
|
|
2162
|
-
|
|
2163
|
-
def by_agent(self, f=None):
|
|
2164
|
-
"""Side-by-side totals per agent for the multi-agent view."""
|
|
2165
|
-
f = dict(f or {})
|
|
2166
|
-
w, p = self.where(f)
|
|
2167
|
-
rows = self.q(f"""SELECT r.agent, COUNT(*) requests, COUNT(DISTINCT r.session_id) sessions,
|
|
2168
|
-
COUNT(DISTINCT r.prompt_id) prompts, SUM(r.billable_tokens) tokens,
|
|
2169
|
-
SUM(r.input_tokens) input_tokens, SUM(r.output_tokens) output_tokens,
|
|
2170
|
-
SUM(r.cache_read_tokens) cache_read_tokens, SUM(r.est_cost_usd) cost,
|
|
2171
|
-
SUM(r.tool_call_count) tool_calls, COUNT(DISTINCT r.day) active_days,
|
|
2172
|
-
GROUP_CONCAT(DISTINCT r.model) models
|
|
2173
|
-
FROM requests r WHERE {w} GROUP BY r.agent ORDER BY cost DESC, requests DESC""", p)
|
|
2174
|
-
daily = self.q(f"""SELECT r.day, r.agent, SUM(r.billable_tokens) tokens, SUM(r.est_cost_usd) cost,
|
|
2175
|
-
COUNT(*) requests FROM requests r WHERE {w} GROUP BY 1, 2 ORDER BY 1""", p)
|
|
2176
|
-
from .agents import AGENTS
|
|
2177
|
-
for r in rows:
|
|
2178
|
-
a = AGENTS.get(r["agent"], {})
|
|
2179
|
-
r["name"], r["data"], r["note"] = a.get("name", r["agent"]), a.get("data"), a.get("note")
|
|
2180
|
-
return {"agents": rows, "daily": daily, "basis": "estimated"}
|
|
2181
|
-
|
|
2182
2151
|
def options(self):
|
|
2183
2152
|
return {
|
|
2184
|
-
"
|
|
2185
|
-
"
|
|
2186
|
-
"projects": self.q("SELECT pr.id project_id, pr.name, pr.path, pr.is_sandbox, pr.agent, COUNT(r.id) n"
|
|
2153
|
+
"models": self.q("SELECT model, COUNT(*) n FROM requests GROUP BY 1 ORDER BY 2 DESC"),
|
|
2154
|
+
"projects": self.q("SELECT pr.id project_id, pr.name, pr.path, pr.is_sandbox, COUNT(r.id) n"
|
|
2187
2155
|
" FROM projects pr LEFT JOIN requests r ON r.project_id=pr.id"
|
|
2188
2156
|
" GROUP BY pr.id HAVING n>0 ORDER BY n DESC"),
|
|
2189
2157
|
"categories": self.q("SELECT category, COUNT(*) n FROM prompts GROUP BY 1 ORDER BY 2 DESC"),
|
|
@@ -2199,5 +2167,6 @@ class Analytics:
|
|
|
2199
2167
|
"SELECT DISTINCT model FROM requests")} & set(self.pricing.models))},
|
|
2200
2168
|
"settings": self.settings,
|
|
2201
2169
|
"version": _version(),
|
|
2170
|
+
"served_from": _served_from(),
|
|
2202
2171
|
"unavailable_label": UNAVAILABLE,
|
|
2203
2172
|
}
|
package/finops/api.py
CHANGED
|
@@ -184,7 +184,7 @@ def filters_from(qs):
|
|
|
184
184
|
f = {
|
|
185
185
|
"start": _date("start"),
|
|
186
186
|
"end": _date("end"),
|
|
187
|
-
"
|
|
187
|
+
"models": lst("models"), "projects": projects,
|
|
188
188
|
"sessions": lst("sessions"), "categories": lst("categories"),
|
|
189
189
|
"include_sandbox": qs.get("include_sandbox", ["1"])[0] != "0",
|
|
190
190
|
"min_cost": _num("min_cost", float),
|
|
@@ -417,7 +417,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
417
417
|
a = A
|
|
418
418
|
qs = parse_qs(urlparse(self.path).query)
|
|
419
419
|
f = filters_from(qs)
|
|
420
|
-
return self.send_json(X.compare(a, f
|
|
420
|
+
return self.send_json(X.compare(a, f))
|
|
421
421
|
if parts == ["suggestions"]:
|
|
422
422
|
with _lock:
|
|
423
423
|
a = A
|
|
@@ -448,13 +448,6 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
448
448
|
parts = path.strip("/").split("/")
|
|
449
449
|
if len(parts) != 4 or not parts[2].isdigit():
|
|
450
450
|
return self.send_json({"ok": False, "error": "bad request"}, 400)
|
|
451
|
-
agent = (self._payload or {}).get("agent") or "claude"
|
|
452
|
-
if agent != "claude":
|
|
453
|
-
from .procs import act_agent
|
|
454
|
-
if parts[3] in ("handover", "compact", "rename"):
|
|
455
|
-
return self.send_json({"ok": False,
|
|
456
|
-
"error": f"{parts[3].capitalize()} is only available for Claude Code."})
|
|
457
|
-
return self.send_json(act_agent(int(parts[2]), parts[3], agent))
|
|
458
451
|
if parts[3] == "handover":
|
|
459
452
|
from .procs import handover
|
|
460
453
|
return self.send_json(handover(int(parts[2]), self._payload))
|
|
@@ -476,6 +469,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
476
469
|
return self.api(path[5:], qs)
|
|
477
470
|
rel = "index.html" if path in ("/", "") else path.lstrip("/")
|
|
478
471
|
fp = os.path.normpath(os.path.join(WEB, rel))
|
|
472
|
+
# Every dashboard has its own URL (/live, /sessions …): a path that isn't a
|
|
473
|
+
# file and has no extension is a page of the app, so serve the app.
|
|
474
|
+
if not os.path.isfile(fp) and "." not in os.path.basename(path.rstrip("/")):
|
|
475
|
+
fp = os.path.join(WEB, "index.html")
|
|
479
476
|
if os.path.commonpath([fp, WEB]) != WEB or not os.path.isfile(fp):
|
|
480
477
|
return self.send_text("not found", "text/plain", code=404)
|
|
481
478
|
ext = os.path.splitext(fp)[1]
|
|
@@ -519,8 +516,6 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
519
516
|
except Exception:
|
|
520
517
|
o["guard_installed"] = False
|
|
521
518
|
return self.send_json(o)
|
|
522
|
-
if route == "by_agent":
|
|
523
|
-
return self.send_json(a.by_agent(f))
|
|
524
519
|
if route == "overview":
|
|
525
520
|
return self.send_json(a.overview(f))
|
|
526
521
|
if route == "burn":
|
|
@@ -584,14 +579,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
584
579
|
from .diagnose import Diagnoser
|
|
585
580
|
return self.send_json(Diagnoser(a).breakdown(f))
|
|
586
581
|
if route == "live":
|
|
587
|
-
from .procs import list_sessions,
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
resume=f"claude --resume {s.get('session_id')}")
|
|
591
|
-
for s in list_sessions(a.pricing)] if not want or "claude" in want else []
|
|
592
|
-
others = list_agent_sessions(a.pricing, [x for x in want if x != "claude"] or None) \
|
|
593
|
-
if not want or any(x != "claude" for x in want) else []
|
|
594
|
-
rows = sorted(claude + others, key=lambda x: -(x.get("context") or 0))
|
|
582
|
+
from .procs import list_sessions, ACTIONS
|
|
583
|
+
rows = sorted((dict(s, resume=f"claude --resume {s.get('session_id')}")
|
|
584
|
+
for s in list_sessions(a.pricing)), key=lambda x: -(x.get("context") or 0))
|
|
595
585
|
return self.send_json({"sessions": rows, "actions": list(ACTIONS)})
|
|
596
586
|
if route.startswith("session_prompts/"):
|
|
597
587
|
from .procs import session_prompts
|