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/README.md +115 -4
- package/config/model_compare.json +4 -2
- package/config/pricing.json +17 -6
- package/config/settings.json +17 -0
- package/finops/actions.py +54 -27
- package/finops/agents.py +13 -39
- package/finops/analytics.py +432 -135
- package/finops/api.py +199 -16
- package/finops/classify.py +6 -0
- package/finops/cloud.py +59 -7
- package/finops/diagnose.py +18 -5
- package/finops/etl.py +171 -7
- package/finops/guard.py +412 -0
- package/finops/integrate.py +78 -0
- package/finops/jev.py +177 -0
- package/finops/localtime.py +53 -0
- package/finops/pricing.py +27 -0
- package/finops/subagents.py +358 -0
- package/package.json +1 -1
- package/run.py +18 -12
- package/web/app.js +1046 -124
- package/web/charts.js +14 -6
- package/web/guide/budgets.png +0 -0
- package/web/styles.css +73 -0
package/finops/analytics.py
CHANGED
|
@@ -17,6 +17,7 @@ from collections import defaultdict
|
|
|
17
17
|
from datetime import date, datetime, timedelta, timezone
|
|
18
18
|
|
|
19
19
|
from .pricing import Pricing
|
|
20
|
+
from . import localtime
|
|
20
21
|
from .segments import is_compaction
|
|
21
22
|
|
|
22
23
|
from .paths import DB_PATH, SETTINGS_PATH, LOCAL_SETTINGS_PATH
|
|
@@ -124,6 +125,9 @@ def _validate_settings(cur, defaults):
|
|
|
124
125
|
if pct is not None:
|
|
125
126
|
bad.append("alert_thresholds_pct")
|
|
126
127
|
cur["alert_thresholds_pct"] = defaults.get("alert_thresholds_pct", [])
|
|
128
|
+
from .guard import validate_guard
|
|
129
|
+
cur["guard"], guard_bad = validate_guard(cur.get("guard"), defaults.get("guard"))
|
|
130
|
+
bad += guard_bad
|
|
127
131
|
for path in bad:
|
|
128
132
|
print(f"finops: settings.local.json has an invalid '{path}'; using the shipped default",
|
|
129
133
|
file=sys.stderr)
|
|
@@ -161,6 +165,13 @@ def _d(s):
|
|
|
161
165
|
return datetime.strptime(s, "%Y-%m-%d").date()
|
|
162
166
|
|
|
163
167
|
|
|
168
|
+
def like(term):
|
|
169
|
+
r"""A LIKE pattern (used with ESCAPE '\') matching `term` literally, so % and _
|
|
170
|
+
in what you type are not wildcards."""
|
|
171
|
+
t = str(term).replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
172
|
+
return f"%{t}%"
|
|
173
|
+
|
|
174
|
+
|
|
164
175
|
def _cumsum(values):
|
|
165
176
|
total = 0.0
|
|
166
177
|
for v in values:
|
|
@@ -198,7 +209,15 @@ class Analytics:
|
|
|
198
209
|
_today = None # tests set this; production uses the clock
|
|
199
210
|
|
|
200
211
|
def today(self):
|
|
201
|
-
return self._today or
|
|
212
|
+
return self._today or localtime.today()
|
|
213
|
+
|
|
214
|
+
def data_end(self):
|
|
215
|
+
"""The last day the warehouse can speak for: today, or the day it was last synced if
|
|
216
|
+
that is earlier. Days after a sync are not idle, they are unread, so rate windows
|
|
217
|
+
end here instead of counting them as zero."""
|
|
218
|
+
built = localtime.local(self.meta.get("built_at"))
|
|
219
|
+
today = self.today()
|
|
220
|
+
return min(today, built.date()) if built and not self._today else today
|
|
202
221
|
|
|
203
222
|
@property
|
|
204
223
|
def db(self):
|
|
@@ -334,6 +353,11 @@ class Analytics:
|
|
|
334
353
|
p).get("n", 0)
|
|
335
354
|
tot["tool_calls"] = self.one(
|
|
336
355
|
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 (an agent without price data, such as Cursor,
|
|
357
|
+
# or a model missing from pricing.json): they count, but at $0.
|
|
358
|
+
tot["unpriced_requests"] = self.one(
|
|
359
|
+
f"SELECT COUNT(*) n FROM requests r WHERE {w} AND r.est_cost_usd = 0 AND r.billable_tokens > 0",
|
|
360
|
+
p).get("n", 0)
|
|
337
361
|
|
|
338
362
|
bp = self.billing_period()
|
|
339
363
|
today = bp["today"]
|
|
@@ -359,8 +383,12 @@ class Analytics:
|
|
|
359
383
|
|
|
360
384
|
# ---------------- burn rate & limits ----------------
|
|
361
385
|
def burn(self, f=None):
|
|
386
|
+
# The billing period is its own window: the page's date range must not move it,
|
|
387
|
+
# or picking "last 7 days" would quietly change what the period projects to.
|
|
388
|
+
f = dict(f or {}, start=None, end=None)
|
|
362
389
|
w, p = self.where(f)
|
|
363
390
|
bp = self.billing_period()
|
|
391
|
+
today, synced = self.today(), self.data_end()
|
|
364
392
|
rows = self.q(f"""SELECT r.day, SUM(r.est_cost_usd) cost, SUM(r.billable_tokens) tokens,
|
|
365
393
|
COUNT(*) requests FROM requests r
|
|
366
394
|
WHERE {w} AND r.day >= ? AND r.day <= ? GROUP BY 1 ORDER BY 1""",
|
|
@@ -368,23 +396,37 @@ class Analytics:
|
|
|
368
396
|
used_cost = sum(r["cost"] for r in rows)
|
|
369
397
|
used_tokens = sum(r["tokens"] for r in rows)
|
|
370
398
|
used_req = sum(r["requests"] for r in rows)
|
|
371
|
-
|
|
399
|
+
# Days after the last sync are unread, not idle: the period average and the
|
|
400
|
+
# projection only count the days the warehouse actually covers.
|
|
401
|
+
unread = max((today - max(synced, _d(bp["start"]) - timedelta(days=1))).days, 0)
|
|
402
|
+
elapsed = max(bp["elapsed_days"] - unread, 1)
|
|
372
403
|
daily_avg = used_cost / elapsed
|
|
373
404
|
|
|
374
405
|
# Trailing rates are measured over the last N CALENDAR days, not only the
|
|
375
406
|
# slice inside the billing period — early in a period that slice is too short
|
|
376
407
|
# to be a rate. Idle days count as zero, because these rates get multiplied by
|
|
377
408
|
# calendar days remaining. This keeps burn and forecast on one methodology.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
409
|
+
# The window ends on the last complete day the warehouse has read.
|
|
410
|
+
rate_end = (synced - timedelta(days=1)).isoformat()
|
|
411
|
+
last7 = self.daily_series(f, days=7, end=rate_end)
|
|
412
|
+
last14 = self.daily_series(f, days=14, end=rate_end)
|
|
381
413
|
avg7 = (sum(r["cost"] for r in last7) / len(last7)) if last7 else 0.0
|
|
382
414
|
tok_avg7 = (sum(r["tokens"] for r in last7) / len(last7)) if last7 else 0.0
|
|
383
415
|
# the projection rate matches Analytics.forecast()'s "expected" scenario
|
|
384
416
|
burn = (sum(r["cost"] for r in last14) / len(last14)) if last14 else daily_avg
|
|
385
417
|
tok_burn = (sum(r["tokens"] for r in last14) / len(last14)) if last14 else 0.0
|
|
386
|
-
|
|
418
|
+
req_burn = (sum(r["requests"] for r in last14) / len(last14)) if last14 else used_req / elapsed
|
|
419
|
+
ahead = bp["remaining_days"] + unread
|
|
420
|
+
projected = used_cost + burn * ahead
|
|
387
421
|
tok_daily = used_tokens / elapsed
|
|
422
|
+
# Zero-filled so the daily chart keeps idle days as gaps, not as missing bars.
|
|
423
|
+
by_day = {r["day"]: r for r in rows}
|
|
424
|
+
series, cur = [], _d(bp["start"])
|
|
425
|
+
while cur <= min(synced, _d(bp["end"])):
|
|
426
|
+
k = cur.isoformat()
|
|
427
|
+
series.append(by_day.get(k) or {"day": k, "cost": 0.0, "tokens": 0, "requests": 0})
|
|
428
|
+
cur += timedelta(days=1)
|
|
429
|
+
rows = series
|
|
388
430
|
|
|
389
431
|
lim = self.settings["limits"]
|
|
390
432
|
out = {
|
|
@@ -396,7 +438,8 @@ class Analytics:
|
|
|
396
438
|
"burn_rate_cost_per_day": burn,
|
|
397
439
|
"burn_rate_window_days": len(last14),
|
|
398
440
|
"projected_period_cost": projected,
|
|
399
|
-
"projected_period_tokens": used_tokens + tok_burn *
|
|
441
|
+
"projected_period_tokens": used_tokens + tok_burn * ahead,
|
|
442
|
+
"synced_through": synced.isoformat(), "unread_days": unread,
|
|
400
443
|
"forecast_basis": "forecast",
|
|
401
444
|
"forecast_note": ("Projection uses the %d-calendar-day mean daily spend, idle days "
|
|
402
445
|
"included as zero — the same rate as the "
|
|
@@ -407,7 +450,7 @@ class Analytics:
|
|
|
407
450
|
for key, used_val, rate, label in (
|
|
408
451
|
("monthly_cost_allowance_usd", used_cost, burn, "cost"),
|
|
409
452
|
("monthly_token_allowance", used_tokens, (tok_burn or tok_daily), "tokens"),
|
|
410
|
-
("monthly_request_allowance", used_req,
|
|
453
|
+
("monthly_request_allowance", used_req, req_burn, "requests"),
|
|
411
454
|
):
|
|
412
455
|
allowance = lim.get(key)
|
|
413
456
|
if not allowance:
|
|
@@ -416,7 +459,7 @@ class Analytics:
|
|
|
416
459
|
remaining = allowance - used_val
|
|
417
460
|
pct = 100.0 * used_val / allowance
|
|
418
461
|
days_left = (remaining / rate) if rate > 0 and remaining > 0 else None
|
|
419
|
-
proj = used_val + rate *
|
|
462
|
+
proj = used_val + rate * ahead
|
|
420
463
|
out["allowances"][label] = {
|
|
421
464
|
"configured": True, "allowance": allowance, "used": used_val,
|
|
422
465
|
"remaining": remaining, "used_pct": round(pct, 1),
|
|
@@ -443,29 +486,64 @@ class Analytics:
|
|
|
443
486
|
# ---------------- timeline ----------------
|
|
444
487
|
def timeline(self, f=None, grain="day"):
|
|
445
488
|
w, p = self.where(f)
|
|
446
|
-
col = "r.day" if grain == "day" else "
|
|
447
|
-
|
|
489
|
+
col = "r.day" if grain == "day" else self._hour_bucket("r.day", "r.hour", "r.ts")
|
|
490
|
+
rows = self.q(f"""
|
|
448
491
|
SELECT {col} bucket, COUNT(*) requests,
|
|
449
492
|
COUNT(DISTINCT r.session_id) sessions,
|
|
450
|
-
COUNT(DISTINCT r.prompt_id) prompts,
|
|
451
493
|
SUM(r.input_tokens) input_tokens, SUM(r.output_tokens) output_tokens,
|
|
452
494
|
SUM(r.cache_read_tokens) cache_read_tokens,
|
|
453
495
|
SUM(r.cache_write_tokens) cache_write_tokens,
|
|
454
496
|
SUM(r.billable_tokens) tokens, SUM(r.est_cost_usd) cost,
|
|
455
497
|
AVG(r.context_tokens) avg_context
|
|
456
498
|
FROM requests r WHERE {w} AND r.day <> '' GROUP BY 1 ORDER BY 1""", p)
|
|
499
|
+
# A prompt is counted once, in the bucket of its first request, so the buckets
|
|
500
|
+
# add up to the prompt total even when a prompt runs past midnight.
|
|
501
|
+
first = "MIN(r.day)" if grain == "day" else f"MIN({self._hour_bucket('r.day', 'r.hour', 'r.ts')})"
|
|
502
|
+
prompts = {x["b"]: x["n"] for x in self.q(f"""
|
|
503
|
+
SELECT b, COUNT(*) n FROM (SELECT {first} b FROM requests r
|
|
504
|
+
WHERE {w} AND r.day <> '' AND r.prompt_id IS NOT NULL GROUP BY r.prompt_id) GROUP BY b""", p)}
|
|
505
|
+
for r in rows:
|
|
506
|
+
r["prompts"] = prompts.get(r["bucket"], 0)
|
|
507
|
+
if grain != "day" or not rows:
|
|
508
|
+
return rows
|
|
509
|
+
# Idle days are part of the range: fill them with zeros so charts keep the gaps.
|
|
510
|
+
by_day = {r["bucket"]: r for r in rows}
|
|
511
|
+
start = _d((f or {}).get("start") or rows[0]["bucket"])
|
|
512
|
+
end = _d((f or {}).get("end") or rows[-1]["bucket"])
|
|
513
|
+
end = min(end, max(self.data_end(), _d(rows[-1]["bucket"])))
|
|
514
|
+
out, cur = [], start
|
|
515
|
+
while cur <= end:
|
|
516
|
+
k = cur.isoformat()
|
|
517
|
+
out.append(by_day.get(k) or {"bucket": k, "requests": 0, "sessions": 0, "prompts": 0,
|
|
518
|
+
"input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0,
|
|
519
|
+
"cache_write_tokens": 0, "tokens": 0, "cost": 0.0,
|
|
520
|
+
"avg_context": None})
|
|
521
|
+
cur += timedelta(days=1)
|
|
522
|
+
return out
|
|
523
|
+
|
|
524
|
+
def _local_cols(self):
|
|
525
|
+
"""True when day/hour were written in local time (see finops/localtime.py)."""
|
|
526
|
+
return self.meta.get("day_basis") == "local"
|
|
527
|
+
|
|
528
|
+
def _hour_bucket(self, day, hour, ts):
|
|
529
|
+
if self._local_cols():
|
|
530
|
+
return f"({day} || 'T' || printf('%02d', {hour}))"
|
|
531
|
+
return f"strftime('%Y-%m-%dT%H', {ts}, 'localtime')" # warehouse from an older version
|
|
457
532
|
|
|
458
533
|
def heatmap(self, f=None):
|
|
459
|
-
"""Spend by weekday x hour, in
|
|
534
|
+
"""Spend by weekday x hour, in your local time (Monday first).
|
|
460
535
|
|
|
461
536
|
Transcripts stamp UTC; bucketing on that would put a 10am IST session at
|
|
462
|
-
4am.
|
|
463
|
-
|
|
537
|
+
4am. The day and hour columns are written in local time when the warehouse is
|
|
538
|
+
built (honouring CLAUDE_FINOPS_TZ); a warehouse built by an older version falls
|
|
539
|
+
back to SQLite's 'localtime', the computer's own zone.
|
|
464
540
|
"""
|
|
465
541
|
w, p = self.where(f)
|
|
542
|
+
wd = "strftime('%w', r.day)" if self._local_cols() else "strftime('%w', r.ts, 'localtime')"
|
|
543
|
+
hr = "r.hour" if self._local_cols() else "strftime('%H', r.ts, 'localtime')"
|
|
466
544
|
rows = self.q(f"""
|
|
467
|
-
SELECT CAST(
|
|
468
|
-
CAST(
|
|
545
|
+
SELECT CAST({wd} AS INTEGER) wd,
|
|
546
|
+
CAST({hr} AS INTEGER) hr,
|
|
469
547
|
COALESCE(SUM(r.est_cost_usd),0) cost, COALESCE(SUM(r.billable_tokens),0) tokens,
|
|
470
548
|
COUNT(*) requests
|
|
471
549
|
FROM requests r WHERE {w} AND r.ts <> '' GROUP BY 1, 2""", p)
|
|
@@ -477,7 +555,7 @@ class Analytics:
|
|
|
477
555
|
c = grid[((r["wd"] + 6) % 7, r["hr"])]
|
|
478
556
|
c["cost"], c["tokens"], c["requests"] = r["cost"], r["tokens"], r["requests"]
|
|
479
557
|
return {"cells": [grid[(d, h)] for d in range(7) for h in range(24)],
|
|
480
|
-
"tz": time.strftime("%Z"), "cost_basis": "estimated"}
|
|
558
|
+
"tz": os.environ.get("CLAUDE_FINOPS_TZ") or time.strftime("%Z"), "cost_basis": "estimated"}
|
|
481
559
|
|
|
482
560
|
# ---------------- models ----------------
|
|
483
561
|
def models(self, f=None):
|
|
@@ -504,9 +582,12 @@ class Analytics:
|
|
|
504
582
|
COUNT(*) n, AVG(r.context_tokens) avg_ctx
|
|
505
583
|
FROM requests r WHERE {w}
|
|
506
584
|
GROUP BY r.model, r.priced_as, r.unpriced_long_context""", p):
|
|
507
|
-
u = util.setdefault(v["model"], {"num": 0.0, "den": 0, "over": 0, "long": 0})
|
|
585
|
+
u = util.setdefault(v["model"], {"num": 0.0, "den": 0, "over": 0, "long": 0, "nowin": 0})
|
|
508
586
|
win = self.pricing.context_window(v["priced_as"] or v["model"])
|
|
509
|
-
if
|
|
587
|
+
if not win:
|
|
588
|
+
u["nowin"] += v["n"] # no known window: neither "over" nor a utilisation
|
|
589
|
+
continue
|
|
590
|
+
if v["u"]:
|
|
510
591
|
u["over"] += v["n"]
|
|
511
592
|
continue
|
|
512
593
|
u["num"] += v["n"] * 100.0 * (v["avg_ctx"] or 0) / win
|
|
@@ -518,6 +599,9 @@ class Analytics:
|
|
|
518
599
|
r["tier"] = self.pricing.tier(r["model"])
|
|
519
600
|
r["context_window"] = self.pricing.context_window(r["model"])
|
|
520
601
|
r["pricing_known"] = bool(r["model_known"])
|
|
602
|
+
# an unlisted Claude model priced as its newest listed sibling (pricing.py)
|
|
603
|
+
r["priced_as_family"] = (None if r["pricing_known"]
|
|
604
|
+
else self.pricing.rates(r["model"]).get("fallback_name"))
|
|
521
605
|
r["cost_pct"] = round(100.0 * r["cost"] / tc, 1)
|
|
522
606
|
r["token_pct"] = round(100.0 * r["tokens"] / tt, 1)
|
|
523
607
|
r["cost_per_1k_output"] = (1000.0 * r["cost"] / r["output_tokens"]) if r["output_tokens"] else None
|
|
@@ -527,16 +611,21 @@ class Analytics:
|
|
|
527
611
|
u = util.get(r["model"], {})
|
|
528
612
|
r["utilization_pct"] = round(u["num"] / u["den"], 1) if u.get("den") else None
|
|
529
613
|
r["over_window_requests"] = u.get("over", 0)
|
|
614
|
+
r["unknown_window_requests"] = u.get("nowin", 0)
|
|
530
615
|
r["long_context_requests"] = u.get("long", 0)
|
|
531
616
|
priced = [r for r in rows if r["tokens"] and r["tier"] != "none"]
|
|
617
|
+
# "Most token-efficient" and "best cost per output" compare paid models with a
|
|
618
|
+
# real sample: one request on a free local model would otherwise win both.
|
|
619
|
+
total_req = sum(r["requests"] for r in rows) or 1
|
|
620
|
+
rated = [r for r in priced if r["cost"] > 0 and r["requests"] >= max(20, total_req * 0.005)]
|
|
532
621
|
superlatives = {}
|
|
533
622
|
if priced:
|
|
534
623
|
superlatives = {
|
|
535
624
|
"most_expensive": max(priced, key=lambda r: r["cost"])["model"],
|
|
536
625
|
"most_used": max(priced, key=lambda r: r["requests"])["model"],
|
|
537
|
-
"most_token_efficient": max(priced, key=lambda r: r["output_per_input"])["model"],
|
|
626
|
+
"most_token_efficient": max(rated or priced, key=lambda r: r["output_per_input"])["model"],
|
|
538
627
|
"best_cost_per_output": min(
|
|
539
|
-
[r for r in priced if r["cost_per_1k_output"]],
|
|
628
|
+
[r for r in (rated or priced) if r["cost_per_1k_output"]],
|
|
540
629
|
key=lambda r: r["cost_per_1k_output"], default={}).get("model"),
|
|
541
630
|
}
|
|
542
631
|
return {"rows": rows, "superlatives": superlatives, "basis": "estimated"}
|
|
@@ -553,12 +642,15 @@ class Analytics:
|
|
|
553
642
|
SUM(r.est_cost_usd) cost, GROUP_CONCAT(DISTINCT r.model) models
|
|
554
643
|
FROM requests r JOIN projects pr ON pr.id = r.project_id
|
|
555
644
|
WHERE {w} GROUP BY pr.id ORDER BY cost DESC""", p)
|
|
645
|
+
# Files touched by the prompts in range, not every file the project ever touched.
|
|
646
|
+
files = {x["project_id"]: x["n"] for x in self.q(f"""
|
|
647
|
+
SELECT ft.project_id, COUNT(DISTINCT ft.path) n FROM files_touched ft
|
|
648
|
+
WHERE ft.prompt_id IN (SELECT DISTINCT r.prompt_id FROM requests r WHERE {w})
|
|
649
|
+
GROUP BY ft.project_id""", p)}
|
|
556
650
|
for r in rows:
|
|
557
651
|
r["avg_cost_per_session"] = r["cost"] / r["sessions"] if r["sessions"] else 0
|
|
558
652
|
r["avg_cost_per_prompt"] = r["cost"] / r["prompts"] if r["prompts"] else None
|
|
559
|
-
r["files_touched"] =
|
|
560
|
-
"SELECT COUNT(DISTINCT path) n FROM files_touched WHERE project_id=?",
|
|
561
|
-
(r["project_id"],))["n"]
|
|
653
|
+
r["files_touched"] = files.get(r["project_id"], 0)
|
|
562
654
|
b = self.settings["budgets"].get("per_project_usd", {}).get(r["name"])
|
|
563
655
|
r["budget_usd"] = b
|
|
564
656
|
r["budget_used_pct"] = round(100.0 * r["cost"] / b, 1) if b else None
|
|
@@ -571,7 +663,8 @@ class Analytics:
|
|
|
571
663
|
rows = self.q(f"""
|
|
572
664
|
SELECT s.id session_id, s.title, s.git_branch, s.cli_version, s.started_at,
|
|
573
665
|
s.ended_at, s.duration_s, s.files_touched, pr.name project, pr.id project_id,
|
|
574
|
-
s.agent, s.source_file,
|
|
666
|
+
s.agent, s.source_file, pr.path project_path,
|
|
667
|
+
s.billable_tokens session_tokens,
|
|
575
668
|
COUNT(DISTINCT r.prompt_id) prompts, COUNT(*) requests,
|
|
576
669
|
SUM(r.tool_call_count) tool_calls,
|
|
577
670
|
SUM(r.input_tokens) input_tokens, SUM(r.output_tokens) output_tokens,
|
|
@@ -590,22 +683,38 @@ class Analytics:
|
|
|
590
683
|
r["output_ratio"] = (r["output_tokens"] or 0) / r["tokens"] if r["tokens"] else 0
|
|
591
684
|
cr, cw = r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0
|
|
592
685
|
r["cache_hit_ratio"] = (cr / (cr + cw)) if (cr + cw) else None
|
|
686
|
+
r["budget_tokens"] = self.session_budget(r["agent"], r.pop("project_path"), r["session_id"])
|
|
593
687
|
r["resume"] = resume_command(r["session_id"], r.pop("agent"), r.pop("source_file"))
|
|
594
688
|
return rows
|
|
595
689
|
|
|
690
|
+
def session_budget(self, agent, project_path, session_id=None):
|
|
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
|
|
698
|
+
from .guard import budget_for
|
|
699
|
+
return budget_for(self.settings.get("guard") or {}, project_path, session_id)
|
|
700
|
+
|
|
596
701
|
def sessions_total(self, f=None):
|
|
597
702
|
w, p = self.where(f)
|
|
598
703
|
return self.one(f"SELECT COUNT(DISTINCT s.id) n FROM requests r "
|
|
599
704
|
f"JOIN sessions s ON s.id = r.session_id "
|
|
600
705
|
f"JOIN projects pr ON pr.id = s.project_id WHERE {w}", p)["n"]
|
|
601
706
|
|
|
707
|
+
@staticmethod
|
|
708
|
+
def _prompt_search(search):
|
|
709
|
+
if not search:
|
|
710
|
+
return "", []
|
|
711
|
+
return (" AND r.prompt_id IN (SELECT id FROM prompts pr WHERE pr.text LIKE ? ESCAPE '\\' "
|
|
712
|
+
"OR pr.session_id LIKE ? ESCAPE '\\' OR pr.category LIKE ? ESCAPE '\\')",
|
|
713
|
+
[like(search)] * 3)
|
|
714
|
+
|
|
602
715
|
def prompts_total(self, f=None, search=None):
|
|
603
716
|
w, p = self.where(f)
|
|
604
|
-
extra, ep =
|
|
605
|
-
if search:
|
|
606
|
-
extra = (" AND r.prompt_id IN (SELECT id FROM prompts pr WHERE pr.text LIKE ? "
|
|
607
|
-
"OR pr.session_id LIKE ? OR pr.category LIKE ?)")
|
|
608
|
-
ep = [f"%{search}%"] * 3
|
|
717
|
+
extra, ep = self._prompt_search(search)
|
|
609
718
|
return self.one(f"SELECT COUNT(DISTINCT r.prompt_id) n FROM requests r "
|
|
610
719
|
f"WHERE {w}{extra} AND r.prompt_id IS NOT NULL", p + ep)["n"]
|
|
611
720
|
|
|
@@ -614,11 +723,9 @@ class Analytics:
|
|
|
614
723
|
ob = {"cost": "pcost DESC", "tokens": "ptokens DESC", "recent": "pr.ts DESC",
|
|
615
724
|
"cheapest": "pcost ASC", "efficiency": "efficiency DESC",
|
|
616
725
|
"length": "pr.char_len DESC"}.get(order, "pcost DESC")
|
|
617
|
-
extra, ep =
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
"OR pr.session_id LIKE ? OR pr.category LIKE ?)")
|
|
621
|
-
ep = [f"%{search}%"] * 3
|
|
726
|
+
extra, ep = self._prompt_search(search)
|
|
727
|
+
# "Cheapest" ranks prompts you paid for: $0 ones ran on free/local models.
|
|
728
|
+
having = "HAVING SUM(r.est_cost_usd) > 0" if order == "cheapest" else ""
|
|
622
729
|
# Aggregate requests per prompt first, rank, and only then join the (large) prompt
|
|
623
730
|
# text for the page being returned. Grouping with the text attached was ~2s a call.
|
|
624
731
|
if order in ("recent", "length"):
|
|
@@ -637,7 +744,7 @@ class Analytics:
|
|
|
637
744
|
GROUP_CONCAT(DISTINCT r.model) models,
|
|
638
745
|
(CAST(SUM(r.output_tokens) AS REAL) / MAX(SUM(r.billable_tokens),1)) efficiency
|
|
639
746
|
FROM requests r WHERE {w}{extra} AND r.prompt_id IS NOT NULL
|
|
640
|
-
GROUP BY r.prompt_id {inner_ob} {inner_lim})
|
|
747
|
+
GROUP BY r.prompt_id {having} {inner_ob} {inner_lim})
|
|
641
748
|
SELECT pr.id prompt_id, pr.uuid, pr.ts, pr.day, pr.text, pr.char_len, pr.word_len,
|
|
642
749
|
pr.category, pr.category_confidence, pr.category_evidence, pr.source,
|
|
643
750
|
pr.session_id, proj.name project, proj.id project_id, s.title session_title,
|
|
@@ -666,12 +773,23 @@ class Analytics:
|
|
|
666
773
|
r["category_evidence"] = []
|
|
667
774
|
return rows
|
|
668
775
|
|
|
669
|
-
def
|
|
776
|
+
def _in_range(self, f, col, val):
|
|
777
|
+
"""The same item's totals inside the selected dates, when a range is set."""
|
|
778
|
+
if not f or not (f.get("start") or f.get("end")):
|
|
779
|
+
return None
|
|
780
|
+
rng = {"start": f.get("start"), "end": f.get("end")}
|
|
781
|
+
w, p = self.where(rng)
|
|
782
|
+
return self.one(f"""SELECT COALESCE(SUM(r.est_cost_usd),0) cost, COUNT(*) requests,
|
|
783
|
+
COUNT(DISTINCT r.prompt_id) prompts, COALESCE(SUM(r.billable_tokens),0) tokens
|
|
784
|
+
FROM requests r WHERE {w} AND r.{col} = ?""", p + [val])
|
|
785
|
+
|
|
786
|
+
def prompt_detail(self, pid, f=None):
|
|
670
787
|
p = self.one("""SELECT pr.*, proj.name project, s.title session_title, s.git_branch
|
|
671
788
|
FROM prompts pr JOIN projects proj ON proj.id=pr.project_id
|
|
672
789
|
LEFT JOIN sessions s ON s.id=pr.session_id WHERE pr.id=?""", (pid,))
|
|
673
790
|
if not p:
|
|
674
791
|
return {"error": "not found"}
|
|
792
|
+
p["in_range"] = self._in_range(f, "prompt_id", pid)
|
|
675
793
|
p["requests"] = self.q(
|
|
676
794
|
"SELECT ts, model, effort, input_tokens, output_tokens, thinking_tokens,"
|
|
677
795
|
" cache_read_tokens, cache_write_tokens, billable_tokens, context_tokens,"
|
|
@@ -692,11 +810,12 @@ class Analytics:
|
|
|
692
810
|
p["category_evidence"] = []
|
|
693
811
|
return p
|
|
694
812
|
|
|
695
|
-
def session_detail(self, sid):
|
|
813
|
+
def session_detail(self, sid, f=None):
|
|
696
814
|
s = self.one("""SELECT s.*, pr.name project FROM sessions s
|
|
697
815
|
JOIN projects pr ON pr.id=s.project_id WHERE s.id=?""", (sid,))
|
|
698
816
|
if not s:
|
|
699
817
|
return {"error": "not found"}
|
|
818
|
+
s["in_range"] = self._in_range(f, "session_id", sid)
|
|
700
819
|
s["prompts"] = self.q("""
|
|
701
820
|
SELECT pr.id prompt_id, pr.ts, pr.category, substr(pr.text,1,220) preview,
|
|
702
821
|
pr.char_len, pr.billable_tokens ptokens, pr.est_cost_usd pcost,
|
|
@@ -724,8 +843,16 @@ class Analytics:
|
|
|
724
843
|
AVG(pr.char_len) avg_prompt_chars
|
|
725
844
|
FROM prompts pr JOIN requests r ON r.prompt_id = pr.id
|
|
726
845
|
WHERE {w} GROUP BY pr.category ORDER BY cost DESC""", p)
|
|
846
|
+
# Averages per prompt, not per request: joined to requests, a prompt with 200
|
|
847
|
+
# requests would otherwise count 200 times in its category's average length.
|
|
848
|
+
per_prompt = {x["category"]: x for x in self.q(f"""
|
|
849
|
+
SELECT pr.category, AVG(pr.category_confidence) confidence, AVG(pr.char_len) avg_prompt_chars
|
|
850
|
+
FROM prompts pr WHERE pr.id IN (SELECT DISTINCT r.prompt_id FROM requests r WHERE {w})
|
|
851
|
+
GROUP BY pr.category""", p)}
|
|
727
852
|
tc = sum(r["cost"] for r in rows) or 1
|
|
728
853
|
for r in rows:
|
|
854
|
+
pp = per_prompt.get(r["category"]) or {}
|
|
855
|
+
r["confidence"], r["avg_prompt_chars"] = pp.get("confidence"), pp.get("avg_prompt_chars")
|
|
729
856
|
r["cost_pct"] = round(100.0 * r["cost"] / tc, 1)
|
|
730
857
|
r["cost_per_prompt"] = r["cost"] / r["prompts"] if r["prompts"] else 0
|
|
731
858
|
return {"rows": rows, "basis": "estimated",
|
|
@@ -833,16 +960,17 @@ class Analytics:
|
|
|
833
960
|
WHERE s.id IN ({ph})""", ids)}
|
|
834
961
|
|
|
835
962
|
def downsample(traj):
|
|
963
|
+
"""Evenly spaced points, each with its real 1-based request number."""
|
|
836
964
|
n = len(traj)
|
|
837
|
-
if n <= trajectory_points
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
return [traj[int(i * step)] for i in range(trajectory_points)]
|
|
965
|
+
idx = range(n) if n <= trajectory_points else \
|
|
966
|
+
[int(i * n / trajectory_points) for i in range(trajectory_points)]
|
|
967
|
+
return [traj[i] for i in idx], [i + 1 for i in idx]
|
|
841
968
|
|
|
842
969
|
out_sessions = []
|
|
843
970
|
for s in ranked:
|
|
844
971
|
m = meta.get(s["session_id"], {})
|
|
845
|
-
traj = downsample(s["traj"])
|
|
972
|
+
traj, traj_idx = downsample(s["traj"])
|
|
973
|
+
cum = list(_cumsum(cost for _, cost in s["traj"]))
|
|
846
974
|
out_sessions.append({
|
|
847
975
|
"session_id": s["session_id"], "title": m.get("title"), "project": m.get("project"),
|
|
848
976
|
"requests": s["requests"], "cost_usd": s["cost_usd"], "max_context": s["max_context"],
|
|
@@ -854,7 +982,8 @@ class Analytics:
|
|
|
854
982
|
"cost_after_pct": {str(t): (round(100.0 * s["cost_after"][t] / s["cost_usd"], 1)
|
|
855
983
|
if s["cost_usd"] else 0.0) for t in thresholds},
|
|
856
984
|
"context_trajectory": [c for c, _ in traj],
|
|
857
|
-
"
|
|
985
|
+
"trajectory_index": traj_idx,
|
|
986
|
+
"cumulative_cost": [round(cum[i - 1], 4) for i in traj_idx],
|
|
858
987
|
"basis": "actual",
|
|
859
988
|
})
|
|
860
989
|
return {
|
|
@@ -945,8 +1074,9 @@ class Analytics:
|
|
|
945
1074
|
% (sum(r["n"] for r in unpriced), ", ".join(sorted({r["model"] for r in unpriced})))
|
|
946
1075
|
if unpriced else ""),
|
|
947
1076
|
"unknown_message": (
|
|
948
|
-
"%d requests used a claude-* model with no entry in pricing.json
|
|
949
|
-
"
|
|
1077
|
+
"%d requests used a claude-* model with no entry in pricing.json, so they "
|
|
1078
|
+
"are priced as the newest listed model of the same family (or $0 when there "
|
|
1079
|
+
"is none). Add pricing.json entries for: %s."
|
|
950
1080
|
% (unknown_requests, ", ".join(unknown_models))
|
|
951
1081
|
if unknown_rows else ""),
|
|
952
1082
|
"basis": "estimated",
|
|
@@ -1054,6 +1184,8 @@ class Analytics:
|
|
|
1054
1184
|
"reads": t["cr"], "writes": t["cw"],
|
|
1055
1185
|
"cost_split": cache_cost,
|
|
1056
1186
|
"breakeven_margin": breakeven_margin,
|
|
1187
|
+
# unclamped: the margin above is capped at ±1 for scoring, this is the real ratio
|
|
1188
|
+
"breakeven_ratio": ((discount - premium) / cache_cost_total) if cache_cost_total else None,
|
|
1057
1189
|
"cost_with_cache": t["cost"], "cost_without_cache": t["cost_nc"],
|
|
1058
1190
|
# Named for what it is. This used to be "estimated_savings_usd", and the
|
|
1059
1191
|
# UI called it a saving; it is the gap to a run that never happened.
|
|
@@ -1095,14 +1227,28 @@ class Analytics:
|
|
|
1095
1227
|
WHERE {w} GROUP BY s.id HAVING MAX(r.context_tokens) >= ?
|
|
1096
1228
|
ORDER BY cost DESC LIMIT 20""", p + [thr])
|
|
1097
1229
|
agg = self.one(f"SELECT AVG(r.context_tokens) a, MAX(r.context_tokens) m FROM requests r WHERE {w}", p)
|
|
1098
|
-
|
|
1230
|
+
# Utilisation against each request's own model window, weighted by requests —
|
|
1231
|
+
# not against the largest window anywhere in the price table (a Gemini window
|
|
1232
|
+
# would set the scale for Claude-only usage).
|
|
1233
|
+
num = den = 0.0
|
|
1234
|
+
wins = defaultdict(int)
|
|
1235
|
+
for v in self.q(f"""SELECT COALESCE(r.priced_as, r.model) m, COUNT(*) n, AVG(r.context_tokens) a
|
|
1236
|
+
FROM requests r WHERE {w} GROUP BY 1""", p):
|
|
1237
|
+
win = self.pricing.context_window(v["m"])
|
|
1238
|
+
if win:
|
|
1239
|
+
num += v["n"] * (v["a"] or 0) / win
|
|
1240
|
+
den += v["n"]
|
|
1241
|
+
wins[win] += v["n"]
|
|
1242
|
+
util_pct = round(100.0 * num / den, 1) if den else None
|
|
1243
|
+
typical_window = max(wins, key=wins.get) if wins else None
|
|
1099
1244
|
return {
|
|
1100
1245
|
"buckets": buckets, "avg_context": agg["a"], "max_context": agg["m"],
|
|
1101
1246
|
"threshold": thr,
|
|
1102
1247
|
"large_context_requests": big["n"], "large_context_cost": big["c"],
|
|
1103
1248
|
"large_context_cost_pct": round(100.0 * big["c"] / total_cost, 1),
|
|
1104
1249
|
"heavy_sessions": heavy_sessions,
|
|
1105
|
-
"typical_context_window":
|
|
1250
|
+
"typical_context_window": typical_window,
|
|
1251
|
+
"context_utilization_pct": util_pct,
|
|
1106
1252
|
"basis": "actual token counts, estimated cost",
|
|
1107
1253
|
}
|
|
1108
1254
|
|
|
@@ -1138,41 +1284,74 @@ class Analytics:
|
|
|
1138
1284
|
"affected": {key: [e[key] for e in evidence if e.get(key)]},
|
|
1139
1285
|
"recommended_action": action, "basis": "estimated"})
|
|
1140
1286
|
|
|
1141
|
-
#
|
|
1142
|
-
#
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1287
|
+
# Who wrote a prompt matters for the advice: a person typed it, a script sent it
|
|
1288
|
+
# through the SDK, or Claude Code wrote it itself (a continuation summary after
|
|
1289
|
+
# compaction). "Stop pasting" is only advice for the first.
|
|
1290
|
+
human = ("(pr.source IS NULL OR pr.source NOT IN ('sdk')) "
|
|
1291
|
+
"AND pr.text NOT LIKE 'This session is being continued%'")
|
|
1292
|
+
sdk = "pr.source = 'sdk'"
|
|
1293
|
+
|
|
1294
|
+
# 1. very long prompts — no excess claimed; split by who sent them.
|
|
1148
1295
|
budget_chars = rules["long_prompt_chars"]
|
|
1149
|
-
for
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1296
|
+
for kind, cond, title, detail, action in (
|
|
1297
|
+
("long_prompts", human, "very long prompts you sent",
|
|
1298
|
+
"Long pasted prompts inflate the cached prefix re-sent on every following turn.",
|
|
1299
|
+
"Move large pasted context into a file and reference it, or summarize first."),
|
|
1300
|
+
("long_sdk_prompts", sdk, "very long prompts sent by scripts (SDK)",
|
|
1301
|
+
"A script or pipeline sends these through the Claude Code SDK; each one starts a "
|
|
1302
|
+
"turn with that whole text.",
|
|
1303
|
+
"Trim what the script sends per call: pass file paths instead of file contents, "
|
|
1304
|
+
"and keep the stable part first so it is cached."),
|
|
1305
|
+
):
|
|
1306
|
+
rows = self.q(f"""SELECT pr.id prompt_id, pr.char_len, substr(pr.text,1,160) preview,
|
|
1307
|
+
pr.session_id, pr.est_cost_usd cost, pr.billable_tokens tokens,
|
|
1308
|
+
pr.request_count requests
|
|
1309
|
+
FROM prompts pr WHERE {pfilter} AND pr.char_len >= ? AND {cond}
|
|
1310
|
+
ORDER BY cost DESC""", p + [budget_chars])
|
|
1311
|
+
for r in rows:
|
|
1312
|
+
r["excess"] = 0.0
|
|
1313
|
+
if rows:
|
|
1314
|
+
add("medium", kind, f"{len(rows)} {title} (≥{budget_chars:,} chars)",
|
|
1315
|
+
detail, rows, action, "prompt_id", "none claimed — flagged for review only")
|
|
1158
1316
|
|
|
1159
1317
|
# 2. duplicate prompts — excess is the cost of the repeats, not the first ask,
|
|
1160
1318
|
# counted only within a single session so cross-session coincidences don't count.
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1319
|
+
window = timedelta(minutes=rules.get("duplicate_window_minutes", 60))
|
|
1320
|
+
occ = self.q(f"""SELECT pr.id, pr.norm_hash, pr.session_id, pr.ts, substr(pr.text,1,160) preview,
|
|
1321
|
+
pr.est_cost_usd cost, pr.billable_tokens tokens
|
|
1322
|
+
FROM prompts pr WHERE {pfilter} AND pr.char_len > 25 AND {human}
|
|
1323
|
+
AND (pr.norm_hash, pr.session_id) IN (
|
|
1324
|
+
SELECT norm_hash, session_id FROM prompts GROUP BY 1, 2 HAVING COUNT(*) > 1)
|
|
1325
|
+
ORDER BY pr.session_id, pr.norm_hash, pr.ts""", p)
|
|
1326
|
+
groups = defaultdict(list)
|
|
1327
|
+
for o in occ:
|
|
1328
|
+
groups[(o["norm_hash"], o["session_id"])].append(o)
|
|
1329
|
+
dups = []
|
|
1330
|
+
for (_, sid), items in groups.items():
|
|
1331
|
+
# A repeat is an ask sent again within the window of the previous one; the
|
|
1332
|
+
# first ask of each run is the real work and is never counted as excess.
|
|
1333
|
+
ids, repeat_cost, prev = [], 0.0, None
|
|
1334
|
+
for o in items:
|
|
1335
|
+
t = localtime.parse(o["ts"])
|
|
1336
|
+
if prev is not None and t and prev["t"] and t - prev["t"] <= window:
|
|
1337
|
+
if not ids:
|
|
1338
|
+
ids.append(prev["id"])
|
|
1339
|
+
ids.append(o["id"])
|
|
1340
|
+
repeat_cost += o["cost"] or 0.0
|
|
1341
|
+
prev = {"t": t, "id": o["id"]}
|
|
1342
|
+
if ids:
|
|
1343
|
+
inc = [o for o in items if o["id"] in ids]
|
|
1344
|
+
dups.append({"norm_hash": items[0]["norm_hash"], "session_id": sid, "n": len(ids),
|
|
1345
|
+
"preview": items[0]["preview"], "cost": sum(o["cost"] or 0 for o in inc),
|
|
1346
|
+
"tokens": sum(o["tokens"] or 0 for o in inc), "prompt_id": ids[0],
|
|
1347
|
+
"affected_ids": ids, "excess": repeat_cost})
|
|
1348
|
+
dups.sort(key=lambda d: -d["cost"])
|
|
1171
1349
|
if dups:
|
|
1172
|
-
add("high", "duplicate_prompts",
|
|
1350
|
+
add("high", "duplicate_prompts",
|
|
1351
|
+
f"{len(dups)} prompts repeated within {rules.get('duplicate_window_minutes', 60)} minutes",
|
|
1173
1352
|
"The same request was sent again, re-paying for context each time.",
|
|
1174
1353
|
dups, "Reuse the earlier answer, or capture the recurring request as a slash command.",
|
|
1175
|
-
"prompt_id", "cost of the
|
|
1354
|
+
"prompt_id", "cost of the repeats, excluding the first ask of each run")
|
|
1176
1355
|
|
|
1177
1356
|
# 3. low-yield sessions — excess is what the session cost ABOVE what the same
|
|
1178
1357
|
# output would have cost at your own median session efficiency.
|
|
@@ -1194,7 +1373,7 @@ class Analytics:
|
|
|
1194
1373
|
proj.name project FROM sessions s JOIN projects proj ON proj.id=s.project_id
|
|
1195
1374
|
WHERE {sfilter} AND s.billable_tokens > ?
|
|
1196
1375
|
AND (CAST(s.output_tokens AS REAL)/MAX(s.billable_tokens,1)) < ?
|
|
1197
|
-
ORDER BY cost DESC
|
|
1376
|
+
ORDER BY cost DESC""",
|
|
1198
1377
|
p + [rules["huge_session_tokens"], cutoff])
|
|
1199
1378
|
for r in low:
|
|
1200
1379
|
r["excess"] = 0.0
|
|
@@ -1232,7 +1411,7 @@ class Analytics:
|
|
|
1232
1411
|
AND pr.output_tokens < ? AND pr.tool_calls = 0 AND pr.est_cost_usd > 0
|
|
1233
1412
|
AND EXISTS (SELECT 1 FROM requests r2 WHERE r2.prompt_id=pr.id
|
|
1234
1413
|
AND r2.model IN ({ph}))
|
|
1235
|
-
ORDER BY cost DESC
|
|
1414
|
+
ORDER BY cost DESC""",
|
|
1236
1415
|
p + [rules["simple_task_output_tokens"]] + frontier)
|
|
1237
1416
|
for r in small:
|
|
1238
1417
|
r["excess"] = 0.0
|
|
@@ -1241,7 +1420,9 @@ class Analytics:
|
|
|
1241
1420
|
f"{len(small)} frontier-model prompts produced under "
|
|
1242
1421
|
f"{rules['simple_task_output_tokens']} output tokens",
|
|
1243
1422
|
"Short, simple turns running on the most expensive model tier.",
|
|
1244
|
-
small, "
|
|
1423
|
+
small, "For standalone lookups, start a fresh session on a cheaper model. "
|
|
1424
|
+
"Switching models mid-session is not cheaper: the new model has to "
|
|
1425
|
+
"write the whole conversation into its own cache first.",
|
|
1245
1426
|
"prompt_id", "none claimed")
|
|
1246
1427
|
|
|
1247
1428
|
# 5. tool loops — excess is the share of the loop beyond the threshold.
|
|
@@ -1249,11 +1430,11 @@ class Analytics:
|
|
|
1249
1430
|
loops = self.q(f"""SELECT pr.id prompt_id, substr(pr.text,1,160) preview, pr.session_id,
|
|
1250
1431
|
pr.tool_calls tools, pr.est_cost_usd cost, pr.billable_tokens tokens
|
|
1251
1432
|
FROM prompts pr WHERE {pfilter} AND pr.tool_calls > ?
|
|
1252
|
-
ORDER BY cost DESC
|
|
1433
|
+
ORDER BY cost DESC""", p + [loop_calls])
|
|
1253
1434
|
for r in loops:
|
|
1254
1435
|
r["excess"] = 0.0
|
|
1255
1436
|
if loops:
|
|
1256
|
-
add("medium", "tool_loops", f"{len(loops)} prompts
|
|
1437
|
+
add("medium", "tool_loops", f"{len(loops)} prompts made more than {loop_calls} tool calls",
|
|
1257
1438
|
"Long agentic loops re-send the whole conversation each step, so cost grows super-linearly.",
|
|
1258
1439
|
loops, "Split the task, or give more precise instructions up front.", "prompt_id",
|
|
1259
1440
|
"none claimed")
|
|
@@ -1280,7 +1461,7 @@ class Analytics:
|
|
|
1280
1461
|
if net > 0:
|
|
1281
1462
|
r["excess"] = net
|
|
1282
1463
|
flagged.append(r)
|
|
1283
|
-
poor = flagged
|
|
1464
|
+
poor = flagged
|
|
1284
1465
|
if poor:
|
|
1285
1466
|
add("medium", "poor_cache_reuse", f"{len(poor)} sessions wrote cache they barely reused",
|
|
1286
1467
|
"Cache writes cost more than plain input; they only pay off when read back repeatedly.",
|
|
@@ -1293,7 +1474,7 @@ class Analytics:
|
|
|
1293
1474
|
s.request_count requests, s.est_cost_usd cost
|
|
1294
1475
|
FROM sessions s JOIN projects proj ON proj.id=s.project_id
|
|
1295
1476
|
WHERE {sfilter} AND s.duration_s > ? AND s.request_count < 30
|
|
1296
|
-
ORDER BY s.duration_s DESC
|
|
1477
|
+
ORDER BY s.duration_s DESC""",
|
|
1297
1478
|
p + [rules["idle_gap_minutes"] * 60 * 4])
|
|
1298
1479
|
for r in idle:
|
|
1299
1480
|
r["excess"] = 0.0
|
|
@@ -1341,7 +1522,7 @@ class Analytics:
|
|
|
1341
1522
|
len(e.get("affected_ids") or [1]), 1))
|
|
1342
1523
|
elif e.get("session_id"):
|
|
1343
1524
|
s_excess[e["session_id"]] = max(s_excess.get(e["session_id"], 0.0), ex)
|
|
1344
|
-
counted_sessions =
|
|
1525
|
+
counted_sessions = {k for k, v in s_excess.items() if v > 0}
|
|
1345
1526
|
outside = 0.0
|
|
1346
1527
|
if p_excess:
|
|
1347
1528
|
owner = {r["id"]: r["session_id"] for r in self.q(
|
|
@@ -1350,7 +1531,13 @@ class Analytics:
|
|
|
1350
1531
|
outside = sum(v for pid_, v in p_excess.items()
|
|
1351
1532
|
if owner.get(pid_) not in counted_sessions)
|
|
1352
1533
|
excess = min(sum(s_excess.values()) + outside, exposed)
|
|
1534
|
+
high = sum(fd["est_cost_usd"] for fd in findings if fd["severity"] == "high")
|
|
1535
|
+
for fd in findings:
|
|
1536
|
+
fd["count"] = len(fd["evidence"])
|
|
1537
|
+
fd["evidence"] = fd["evidence"][:15]
|
|
1538
|
+
fd["affected"] = {k: v[:15] for k, v in fd["affected"].items()}
|
|
1353
1539
|
return {"findings": findings,
|
|
1540
|
+
"high_severity_pct": round(min(100.0 * high / total, 100.0), 1) if total else 0,
|
|
1354
1541
|
"estimated_excess_usd": excess,
|
|
1355
1542
|
"excess_pct": round(100.0 * excess / total, 1) if total else 0,
|
|
1356
1543
|
"exposed_cost_usd": exposed,
|
|
@@ -1456,42 +1643,58 @@ class Analytics:
|
|
|
1456
1643
|
"estimated_savings_usd": None, "estimated_savings_pct": None,
|
|
1457
1644
|
"caveat": "No saving is claimed: what an uncached run would have cost is a "
|
|
1458
1645
|
"counterfactual, not money you avoided.",
|
|
1459
|
-
"basis": "
|
|
1646
|
+
"basis": "estimated",
|
|
1460
1647
|
})
|
|
1461
1648
|
|
|
1462
|
-
|
|
1463
|
-
|
|
1649
|
+
# Same definition as Context hygiene and the scorecard: main-conversation turns
|
|
1650
|
+
# only (subagents run on their own prefix), at or above the threshold.
|
|
1651
|
+
thr = self.settings["waste_rules"]["large_context_tokens"]
|
|
1652
|
+
hy = self.hygiene(f, top=0)
|
|
1653
|
+
big = hy["above"].get(str(thr))
|
|
1654
|
+
if big is None:
|
|
1655
|
+
w2, p2 = self.where(f)
|
|
1656
|
+
n = self.one(f"""SELECT COUNT(*) n, COALESCE(SUM(r.est_cost_usd),0) c FROM requests r
|
|
1657
|
+
WHERE {w2} AND r.is_sidechain=0 AND r.context_tokens >= ?""", p2 + [thr])
|
|
1658
|
+
big = {"requests": n["n"], "cost_usd": n["c"],
|
|
1659
|
+
"share_pct": round(100.0 * n["c"] / hy["cost_usd"], 1) if hy["cost_usd"] else 0.0}
|
|
1660
|
+
if big["share_pct"] > 15:
|
|
1464
1661
|
recs.append({
|
|
1465
1662
|
"type": "context_reduction", "confidence": "observed",
|
|
1466
|
-
"title": f"{
|
|
1467
|
-
f"{
|
|
1468
|
-
"detail": (f"{
|
|
1663
|
+
"title": f"{big['share_pct']}% of spend comes from requests at or above "
|
|
1664
|
+
f"{thr//1000}K context",
|
|
1665
|
+
"detail": (f"{big['requests']:,} main-conversation requests re-sent a large prefix "
|
|
1469
1666
|
f"on every turn. /compact or a fresh session resets it; how much "
|
|
1470
1667
|
f"that would have saved depends on what the work needed, and is "
|
|
1471
1668
|
f"not estimated here."),
|
|
1472
|
-
"scope": f"{
|
|
1473
|
-
"actual_cost_usd":
|
|
1669
|
+
"scope": f"{big['requests']:,} requests",
|
|
1670
|
+
"actual_cost_usd": big["cost_usd"],
|
|
1474
1671
|
"estimated_alternative_cost_usd": None,
|
|
1475
1672
|
"estimated_savings_usd": None, "estimated_savings_pct": None,
|
|
1476
1673
|
"caveat": "Observed share of spend. No reduction is assumed.",
|
|
1477
|
-
"basis": "
|
|
1674
|
+
"basis": "estimated",
|
|
1478
1675
|
})
|
|
1479
1676
|
recs.sort(key=lambda r: -(r.get("actual_cost_usd") or 0))
|
|
1480
1677
|
return {"recommendations": recs, "basis": "actual"}
|
|
1481
1678
|
|
|
1482
1679
|
# ---------------- forecast ----------------
|
|
1483
1680
|
def forecast(self, f=None):
|
|
1681
|
+
# A forecast always looks forward from your latest data: the page's date range
|
|
1682
|
+
# would otherwise turn "August" into a $0 forecast for the current period.
|
|
1683
|
+
f = dict(f or {}, start=None, end=None)
|
|
1484
1684
|
w, p = self.where(f)
|
|
1485
1685
|
bp = self.billing_period()
|
|
1486
1686
|
rows = self.q(f"""SELECT r.day, SUM(r.est_cost_usd) cost, SUM(r.billable_tokens) tokens
|
|
1487
1687
|
FROM requests r WHERE {w} AND r.day <> '' GROUP BY 1 ORDER BY 1""", p)
|
|
1488
1688
|
if not rows:
|
|
1489
1689
|
return {"available": False, "message": "No usage in the selected range."}
|
|
1490
|
-
# calendar days, idle days as zero, excluding
|
|
1491
|
-
# below is multiplied by calendar days remaining, so a per-active-day
|
|
1492
|
-
# would overstate every scenario and a partial
|
|
1493
|
-
|
|
1494
|
-
|
|
1690
|
+
# calendar days, idle days as zero, excluding the last read day (still partial) —
|
|
1691
|
+
# the rate below is multiplied by calendar days remaining, so a per-active-day
|
|
1692
|
+
# mean would overstate every scenario and a partial day would understate it.
|
|
1693
|
+
# Days after the last sync are unread, not idle: they are projected, not zeroed.
|
|
1694
|
+
synced = self.data_end()
|
|
1695
|
+
unread = max((self.today() - max(synced, _d(bp["start"]) - timedelta(days=1))).days, 0)
|
|
1696
|
+
last_full = (synced - timedelta(days=1)).isoformat()
|
|
1697
|
+
recent = [r for r in self.daily_series(f, days=14, end=last_full)] # complete days only
|
|
1495
1698
|
priced = [r["cost"] for r in recent]
|
|
1496
1699
|
sample_days = sum(1 for c in priced if c > 0)
|
|
1497
1700
|
mean = statistics.fmean(priced) if priced else 0.0
|
|
@@ -1499,14 +1702,15 @@ class Analytics:
|
|
|
1499
1702
|
in_period = [r for r in rows if bp["start"] <= r["day"] <= bp["end"]]
|
|
1500
1703
|
used = sum(r["cost"] for r in in_period)
|
|
1501
1704
|
used_tok = sum(r["tokens"] for r in in_period)
|
|
1502
|
-
left = bp["remaining_days"]
|
|
1705
|
+
left = bp["remaining_days"] + unread
|
|
1503
1706
|
insufficient = sample_days < 7
|
|
1504
1707
|
|
|
1505
1708
|
def band(rate, spread=0.0):
|
|
1506
1709
|
# spend on different days is treated as independent, so the spread of a
|
|
1507
1710
|
# sum over `left` days grows with sqrt(left), not left
|
|
1508
|
-
|
|
1509
|
-
|
|
1711
|
+
end = used + rate * left + spread * (left ** 0.5)
|
|
1712
|
+
# the daily rate each scenario implies, so the table doesn't repeat one number
|
|
1713
|
+
return {"daily_rate": (end - used) / left if left else rate, "end_of_period_cost": end}
|
|
1510
1714
|
|
|
1511
1715
|
scenarios = {"expected": band(mean)}
|
|
1512
1716
|
if not insufficient:
|
|
@@ -1521,14 +1725,15 @@ class Analytics:
|
|
|
1521
1725
|
"available": True,
|
|
1522
1726
|
"method": ("mean of the last 14 complete calendar days (idle days as zero); "
|
|
1523
1727
|
"bands are ±1 sd × sqrt(days remaining)"),
|
|
1524
|
-
"sample_days": sample_days,
|
|
1728
|
+
"sample_days": sample_days, "window_days": len(recent),
|
|
1729
|
+
"synced_through": synced.isoformat(), "unread_days": unread,
|
|
1525
1730
|
"insufficient_history": insufficient,
|
|
1526
1731
|
"daily_mean": mean, "daily_stdev": sd,
|
|
1527
1732
|
"period_used": used, "period_used_tokens": used_tok,
|
|
1528
1733
|
"remaining_days": left,
|
|
1529
1734
|
"scenarios": scenarios,
|
|
1530
1735
|
"end_of_period_tokens": used_tok + tok_mean * left,
|
|
1531
|
-
"estimated_monthly_cost":
|
|
1736
|
+
"estimated_monthly_cost": mean * 30,
|
|
1532
1737
|
"basis": "forecast",
|
|
1533
1738
|
}
|
|
1534
1739
|
allowance = self.settings["limits"].get("monthly_cost_allowance_usd")
|
|
@@ -1575,31 +1780,101 @@ class Analytics:
|
|
|
1575
1780
|
"thresholds_breached": breached, "thresholds_forecast_breach": fbreach,
|
|
1576
1781
|
"unit": unit}
|
|
1577
1782
|
|
|
1783
|
+
out["suggest"] = self.budget_suggestions(f)
|
|
1578
1784
|
fc_cost = fc.get("scenarios", {}).get("expected", {}).get("end_of_period_cost")
|
|
1579
1785
|
out["lines"].append(line("Monthly spend", b.get("monthly_usd"), period["c"], fc_cost))
|
|
1580
1786
|
out["lines"].append(line("Daily spend", b.get("daily_usd"), today["c"], today["c"]))
|
|
1581
1787
|
out["lines"].append(line("Monthly tokens", b.get("monthly_tokens"), period["t"],
|
|
1582
1788
|
fc.get("end_of_period_tokens"), unit="tokens"))
|
|
1789
|
+
g = self.settings.get("guard") or {}
|
|
1790
|
+
from .guard import has_any_budget
|
|
1791
|
+
if has_any_budget(g):
|
|
1792
|
+
out["lines"].append(self._session_budget_line(f, bp, line))
|
|
1793
|
+
else:
|
|
1794
|
+
out["lines"].append(line("Per-session tokens", None, 0, None, unit="tokens"))
|
|
1795
|
+
# Per-project and per-model lines follow the agent selection like every other line.
|
|
1796
|
+
wa, pa = self.where({"agents": (f or {}).get("agents") or []})
|
|
1583
1797
|
for proj, bud in (b.get("per_project_usd") or {}).items():
|
|
1584
|
-
act = self.one("""SELECT COALESCE(SUM(r.est_cost_usd),0) c FROM requests r
|
|
1798
|
+
act = self.one(f"""SELECT COALESCE(SUM(r.est_cost_usd),0) c FROM requests r
|
|
1585
1799
|
JOIN projects pr ON pr.id=r.project_id
|
|
1586
|
-
WHERE pr.name=? AND r.day>=? AND r.day<=?""",
|
|
1587
|
-
|
|
1800
|
+
WHERE {wa} AND pr.name=? AND r.day>=? AND r.day<=?""",
|
|
1801
|
+
pa + [proj, bp["start"], bp["end"]])["c"]
|
|
1588
1802
|
out["lines"].append(line(f"Project: {proj}", bud, act, None))
|
|
1589
1803
|
for model, bud in (b.get("per_model_usd") or {}).items():
|
|
1590
|
-
act = self.one("""SELECT COALESCE(SUM(est_cost_usd),0) c FROM requests
|
|
1591
|
-
WHERE model=? AND day>=? AND day<=?""",
|
|
1592
|
-
|
|
1804
|
+
act = self.one(f"""SELECT COALESCE(SUM(r.est_cost_usd),0) c FROM requests r
|
|
1805
|
+
WHERE {wa} AND r.model=? AND r.day>=? AND r.day<=?""",
|
|
1806
|
+
pa + [model, bp["start"], bp["end"]])["c"]
|
|
1593
1807
|
out["lines"].append(line(f"Model: {self.pricing.display_name(model)}", bud, act, None))
|
|
1594
1808
|
return out
|
|
1595
1809
|
|
|
1810
|
+
def budget_suggestions(self, f=None):
|
|
1811
|
+
"""Figures the Budgets form offers as suggestions: recent spend and session sizes.
|
|
1812
|
+
|
|
1813
|
+
Your last 30 days of data (ending on the last synced day, whatever date range the
|
|
1814
|
+
page shows), under the other filters; session sizes are Claude Code sessions'
|
|
1815
|
+
whole-session billable tokens for sessions active in those 30 days (a sorted sample,
|
|
1816
|
+
at most 2,000 values), so the page can say how many recent sessions a budget would
|
|
1817
|
+
have caught.
|
|
1818
|
+
"""
|
|
1819
|
+
w, p = self.where(dict(f or {}, start=None, end=None))
|
|
1820
|
+
end = self.data_end().isoformat()
|
|
1821
|
+
start = (self.data_end() - timedelta(days=29)).isoformat()
|
|
1822
|
+
days = [r["c"] for r in self.q(
|
|
1823
|
+
f"SELECT r.day, SUM(r.est_cost_usd) c FROM requests r WHERE {w} AND r.day >= ? "
|
|
1824
|
+
f"AND r.day <= ? GROUP BY r.day", p + [start, end])]
|
|
1825
|
+
days += [0.0] * (30 - len(days)) # idle days are part of the 30
|
|
1826
|
+
tot = self.one(f"SELECT COALESCE(SUM(r.est_cost_usd),0) c, COALESCE(SUM(r.billable_tokens),0) t "
|
|
1827
|
+
f"FROM requests r WHERE {w} AND r.day >= ? AND r.day <= ?", p + [start, end])
|
|
1828
|
+
sizes = [r["t"] for r in self.q(
|
|
1829
|
+
"SELECT billable_tokens t FROM sessions WHERE agent='claude' AND billable_tokens > 0 "
|
|
1830
|
+
"AND id IN (SELECT DISTINCT session_id FROM requests WHERE day >= ? AND day <= ?) "
|
|
1831
|
+
"ORDER BY t", (start, end))]
|
|
1832
|
+
if len(sizes) > 2000:
|
|
1833
|
+
step = len(sizes) / 2000.0
|
|
1834
|
+
sizes = [sizes[int(i * step)] for i in range(2000)]
|
|
1835
|
+
days.sort()
|
|
1836
|
+
return {"spend_30d": round(tot["c"], 2), "tokens_30d": tot["t"],
|
|
1837
|
+
"daily_avg": round(tot["c"] / 30.0, 2),
|
|
1838
|
+
"daily_p90": round(days[min(len(days) - 1, int(len(days) * 0.9))], 2) if days else 0,
|
|
1839
|
+
"session_tokens": sizes}
|
|
1840
|
+
|
|
1841
|
+
def _session_budget_line(self, f, bp, line):
|
|
1842
|
+
"""Largest Claude Code session this period against its own budget, and who went over."""
|
|
1843
|
+
w, p = self.where(f)
|
|
1844
|
+
rows = self.q(f"""SELECT s.id session_id, s.title, pr.name project, pr.path path,
|
|
1845
|
+
s.billable_tokens tokens
|
|
1846
|
+
FROM sessions s JOIN projects pr ON pr.id = s.project_id
|
|
1847
|
+
WHERE s.agent = 'claude' AND s.id IN (
|
|
1848
|
+
SELECT r.session_id FROM requests r
|
|
1849
|
+
WHERE {w} AND r.day >= ? AND r.day <= ?)""",
|
|
1850
|
+
p + [bp["start"], bp["end"]])
|
|
1851
|
+
scored = []
|
|
1852
|
+
for r in rows:
|
|
1853
|
+
bud = self.session_budget("claude", r.pop("path"), r["session_id"])
|
|
1854
|
+
if bud:
|
|
1855
|
+
r["budget"], r["pct"] = bud, round(100.0 * (r["tokens"] or 0) / bud, 1)
|
|
1856
|
+
scored.append(r)
|
|
1857
|
+
scored.sort(key=lambda r: r["pct"], reverse=True)
|
|
1858
|
+
top = scored[0] if scored else None
|
|
1859
|
+
g = self.settings.get("guard") or {}
|
|
1860
|
+
ln = line("Per-session tokens", top["budget"] if top else g.get("session_tokens"),
|
|
1861
|
+
top["tokens"] if top else 0, None, unit="tokens")
|
|
1862
|
+
over = [r for r in scored if r["pct"] >= 100]
|
|
1863
|
+
ln["sessions_over"] = len(over)
|
|
1864
|
+
ln["top_over"] = over[:5]
|
|
1865
|
+
ln["basis"] = "largest session this period vs its budget"
|
|
1866
|
+
return ln
|
|
1867
|
+
|
|
1596
1868
|
# ---------------- anomalies ----------------
|
|
1597
1869
|
def anomalies(self, f=None):
|
|
1598
1870
|
w, p = self.where(f)
|
|
1599
1871
|
cfg = self.settings["anomaly"]
|
|
1600
1872
|
found = []
|
|
1601
|
-
|
|
1602
|
-
|
|
1873
|
+
# complete days only: the last synced day may be partial
|
|
1874
|
+
last_full = self.data_end() - timedelta(days=1)
|
|
1875
|
+
if (f or {}).get("end"):
|
|
1876
|
+
last_full = min(last_full, _d(f["end"]))
|
|
1877
|
+
series = [d for d in self.daily_series(f, end=last_full.isoformat())]
|
|
1603
1878
|
priced = [d for d in series if d["cost"] > 0]
|
|
1604
1879
|
if len(priced) >= 14:
|
|
1605
1880
|
# Median/MAD, not mean/stdev: unpriced $0 days from agents without pricing
|
|
@@ -1617,7 +1892,7 @@ class Analytics:
|
|
|
1617
1892
|
if score >= cfg.get("daily_robust_z", 3.5) and ratio >= cfg["daily_ratio"]:
|
|
1618
1893
|
found.append({
|
|
1619
1894
|
"severity": "high", "type": "daily_spike", "date": d["day"],
|
|
1620
|
-
"title": f"{d['day']} spend was {ratio:.1f}x your
|
|
1895
|
+
"title": f"{d['day']} spend was {ratio:.1f}x your typical (median) day",
|
|
1621
1896
|
"detail": f"${d['cost']:,.2f} vs a ${med:,.2f} median priced day (robust z={score:.1f}).",
|
|
1622
1897
|
"metric_value": d["cost"], "baseline": med, "ratio": round(ratio, 2),
|
|
1623
1898
|
"drilldown": {"filter": {"start": d["day"], "end": d["day"]}},
|
|
@@ -1655,7 +1930,7 @@ class Analytics:
|
|
|
1655
1930
|
})
|
|
1656
1931
|
# week-over-week model shift
|
|
1657
1932
|
if self.last_day:
|
|
1658
|
-
end =
|
|
1933
|
+
end = last_full # the last complete day you have data for, within the range
|
|
1659
1934
|
cur_s = (end - timedelta(days=6)).isoformat()
|
|
1660
1935
|
prev_s, prev_e = (end - timedelta(days=13)).isoformat(), (end - timedelta(days=7)).isoformat()
|
|
1661
1936
|
for m in self.q(f"SELECT DISTINCT r.model FROM requests r WHERE {w}", p):
|
|
@@ -1675,7 +1950,8 @@ class Analytics:
|
|
|
1675
1950
|
"drilldown": {"filter": {"models": [model], "start": cur_s}},
|
|
1676
1951
|
"basis": "estimated",
|
|
1677
1952
|
})
|
|
1678
|
-
|
|
1953
|
+
sev = {"high": 0, "medium": 1, "low": 2}
|
|
1954
|
+
found.sort(key=lambda x: (sev.get(x["severity"], 3), -x.get("ratio", 0)))
|
|
1679
1955
|
return {"anomalies": found[:25], "basis": "estimated"}
|
|
1680
1956
|
|
|
1681
1957
|
# ---------------- scorecard ----------------
|
|
@@ -1699,9 +1975,12 @@ class Analytics:
|
|
|
1699
1975
|
if margin is None:
|
|
1700
1976
|
dim("Cache break-even", 50, "No cache activity in range", 1.0)
|
|
1701
1977
|
else:
|
|
1978
|
+
ratio = eff["cache"].get("breakeven_ratio") or margin
|
|
1702
1979
|
dim("Cache break-even", 50 + margin * 50,
|
|
1703
|
-
f"Caching
|
|
1704
|
-
|
|
1980
|
+
(f"Caching saved {ratio:.1f}× what it cost (read discount net of write premium)."
|
|
1981
|
+
if ratio >= 1 else
|
|
1982
|
+
f"Caching returned {ratio*100:.0f}% of its cost as read discount net of write premium."),
|
|
1983
|
+
1.0)
|
|
1705
1984
|
|
|
1706
1985
|
ml = next((l for l in bud["lines"] if l["name"] == "Monthly spend"), None)
|
|
1707
1986
|
if ml and ml.get("configured"):
|
|
@@ -1720,7 +1999,7 @@ class Analytics:
|
|
|
1720
1999
|
return {
|
|
1721
2000
|
"score": total,
|
|
1722
2001
|
"dimensions": dims,
|
|
1723
|
-
"what_is_good": [f"{d['name']}: {d['detail']}" for d in strong if d["score"] >=
|
|
2002
|
+
"what_is_good": [f"{d['name']}: {d['detail']}" for d in strong if d["score"] >= 70],
|
|
1724
2003
|
"needs_attention": [f"{d['name']}: {d['detail']}" for d in weak if d["score"] < 70],
|
|
1725
2004
|
"biggest_opportunity": (
|
|
1726
2005
|
{"title": top["title"], "detail": top["detail"],
|
|
@@ -1742,15 +2021,19 @@ class Analytics:
|
|
|
1742
2021
|
ov = self.overview(f)
|
|
1743
2022
|
|
|
1744
2023
|
for a in anos["anomalies"][:2]:
|
|
1745
|
-
actions.append({"priority":
|
|
2024
|
+
actions.append({"priority": {"high": 1, "medium": 2}.get(a["severity"], 3),
|
|
2025
|
+
"kind": "anomaly", "text": a["title"],
|
|
1746
2026
|
"detail": a["detail"], "drilldown": a.get("drilldown"),
|
|
1747
2027
|
"basis": "estimated"})
|
|
1748
2028
|
for wf in wst["findings"][:2]:
|
|
1749
2029
|
actions.append({"priority": 2, "kind": "waste",
|
|
1750
2030
|
"text": wf["title"],
|
|
1751
|
-
"detail": f"{wf['detail']} ~${wf['est_excess_usd']:,.2f} estimated "
|
|
1752
|
-
|
|
1753
|
-
|
|
2031
|
+
"detail": (f"{wf['detail']} ~${wf['est_excess_usd']:,.2f} estimated "
|
|
2032
|
+
f"excess across ${wf['est_cost_usd']:,.2f} of exposed spend. "
|
|
2033
|
+
if wf["est_excess_usd"] > 0 else
|
|
2034
|
+
f"{wf['detail']} No excess is claimed; "
|
|
2035
|
+
f"${wf['est_cost_usd']:,.2f} of spend sits in these items. ")
|
|
2036
|
+
+ wf["recommended_action"],
|
|
1754
2037
|
"basis": "estimated"})
|
|
1755
2038
|
for r in recs["recommendations"][:2]:
|
|
1756
2039
|
if r["type"] == "cache_working":
|
|
@@ -1794,14 +2077,14 @@ class Analytics:
|
|
|
1794
2077
|
WHERE {w} GROUP BY 1 ORDER BY 2 DESC""", p)
|
|
1795
2078
|
files = self.q(f"""SELECT ft.path, COUNT(*) touches, COUNT(DISTINCT ft.session_id) sessions,
|
|
1796
2079
|
GROUP_CONCAT(DISTINCT ft.op) ops
|
|
1797
|
-
FROM files_touched ft WHERE ft.
|
|
1798
|
-
(SELECT DISTINCT r.
|
|
2080
|
+
FROM files_touched ft WHERE ft.prompt_id IN
|
|
2081
|
+
(SELECT DISTINCT r.prompt_id FROM requests r WHERE {w})
|
|
1799
2082
|
GROUP BY 1 ORDER BY 2 DESC LIMIT 40""", p)
|
|
1800
2083
|
branches = self.q(f"""SELECT COALESCE(s.git_branch,'(none)') branch,
|
|
1801
2084
|
COUNT(DISTINCT s.id) sessions, SUM(r.est_cost_usd) cost,
|
|
1802
2085
|
SUM(r.billable_tokens) tokens
|
|
1803
2086
|
FROM requests r JOIN sessions s ON s.id=r.session_id
|
|
1804
|
-
WHERE {w} GROUP BY 1 ORDER BY cost DESC
|
|
2087
|
+
WHERE {w} GROUP BY 1 ORDER BY cost DESC""", p)
|
|
1805
2088
|
repos = [r for r in self.projects(f) if not r["is_sandbox"]]
|
|
1806
2089
|
return {
|
|
1807
2090
|
"tools": tools, "files": files, "branches": branches, "repositories": repos,
|
|
@@ -1821,32 +2104,41 @@ class Analytics:
|
|
|
1821
2104
|
|
|
1822
2105
|
# ---------------- global search ----------------
|
|
1823
2106
|
def search(self, term, limit=40):
|
|
1824
|
-
|
|
2107
|
+
# One more row than shown, so the page can say "40+" instead of a capped count.
|
|
2108
|
+
pat, limit = like(term), limit + 1
|
|
2109
|
+
out = self._search(term, pat, limit)
|
|
2110
|
+
for k, v in list(out.items()):
|
|
2111
|
+
if isinstance(v, list):
|
|
2112
|
+
out[k + "_more"] = len(v) >= limit
|
|
2113
|
+
out[k] = v[:limit - 1]
|
|
2114
|
+
return out
|
|
2115
|
+
|
|
2116
|
+
def _search(self, term, like, limit):
|
|
1825
2117
|
return {
|
|
1826
2118
|
"term": term,
|
|
1827
2119
|
"prompts": self.q(
|
|
1828
2120
|
"SELECT id prompt_id, ts, category, session_id, substr(text,1,240) preview,"
|
|
1829
|
-
" est_cost_usd, billable_tokens FROM prompts WHERE text LIKE ?"
|
|
2121
|
+
" est_cost_usd, billable_tokens FROM prompts WHERE text LIKE ? ESCAPE '\\'"
|
|
1830
2122
|
" ORDER BY est_cost_usd DESC LIMIT ?", (like, limit)),
|
|
1831
2123
|
"sessions": self.q(
|
|
1832
2124
|
"SELECT s.id session_id, s.title, s.git_branch, pr.name project, s.started_at,"
|
|
1833
2125
|
" s.est_cost_usd, s.billable_tokens FROM sessions s"
|
|
1834
2126
|
" JOIN projects pr ON pr.id=s.project_id"
|
|
1835
|
-
" WHERE s.id LIKE ? OR s.title LIKE ? OR s.git_branch LIKE ?"
|
|
2127
|
+
" WHERE s.id LIKE ? ESCAPE '\\' OR s.title LIKE ? ESCAPE '\\' OR s.git_branch LIKE ? ESCAPE '\\'"
|
|
1836
2128
|
" ORDER BY s.est_cost_usd DESC LIMIT ?", (like, like, like, limit)),
|
|
1837
2129
|
"projects": self.q(
|
|
1838
2130
|
"SELECT id project_id, name, path, slug FROM projects"
|
|
1839
|
-
" WHERE name LIKE ? OR path LIKE ? LIMIT ?", (like, like, limit)),
|
|
2131
|
+
" WHERE name LIKE ? ESCAPE '\\' OR path LIKE ? ESCAPE '\\' LIMIT ?", (like, like, limit)),
|
|
1840
2132
|
"models": self.q(
|
|
1841
2133
|
"SELECT model, COUNT(*) requests, SUM(est_cost_usd) cost FROM requests"
|
|
1842
|
-
" WHERE model LIKE ? GROUP BY 1", (like,)),
|
|
2134
|
+
" WHERE model LIKE ? ESCAPE '\\' GROUP BY 1", (like,)),
|
|
1843
2135
|
"tools": self.q(
|
|
1844
2136
|
"SELECT name, target, COUNT(*) n FROM tool_calls"
|
|
1845
|
-
" WHERE name LIKE ? OR target LIKE ? GROUP BY name, target"
|
|
2137
|
+
" WHERE name LIKE ? ESCAPE '\\' OR target LIKE ? ESCAPE '\\' GROUP BY name, target"
|
|
1846
2138
|
" ORDER BY n DESC LIMIT ?", (like, like, limit)),
|
|
1847
2139
|
"days": self.q(
|
|
1848
2140
|
"SELECT day, COUNT(*) requests, SUM(est_cost_usd) cost FROM requests"
|
|
1849
|
-
" WHERE day LIKE ? GROUP BY 1 ORDER BY 1", (like,)),
|
|
2141
|
+
" WHERE day LIKE ? ESCAPE '\\' GROUP BY 1 ORDER BY 1", (like,)),
|
|
1850
2142
|
}
|
|
1851
2143
|
|
|
1852
2144
|
# ---------------- filter option lists ----------------
|
|
@@ -1891,15 +2183,20 @@ class Analytics:
|
|
|
1891
2183
|
return {
|
|
1892
2184
|
"agents": self.agents(),
|
|
1893
2185
|
"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"
|
|
2186
|
+
"projects": self.q("SELECT pr.id project_id, pr.name, pr.path, pr.is_sandbox, pr.agent, COUNT(r.id) n"
|
|
1895
2187
|
" FROM projects pr LEFT JOIN requests r ON r.project_id=pr.id"
|
|
1896
2188
|
" GROUP BY pr.id HAVING n>0 ORDER BY n DESC"),
|
|
1897
2189
|
"categories": self.q("SELECT category, COUNT(*) n FROM prompts GROUP BY 1 ORDER BY 2 DESC"),
|
|
1898
|
-
"date_range": {"first": self.first_day, "last": self.last_day
|
|
2190
|
+
"date_range": {"first": self.first_day, "last": self.last_day,
|
|
2191
|
+
"today": self.today().isoformat(), "synced_through": self.data_end().isoformat()},
|
|
1899
2192
|
"billing_period": self.billing_period(),
|
|
1900
2193
|
"meta": self.meta,
|
|
1901
2194
|
"pricing": {"updated": self.pricing.updated, "source": self.pricing.source,
|
|
1902
|
-
"models": self.pricing.models
|
|
2195
|
+
"models": self.pricing.models,
|
|
2196
|
+
# price-table keys your data actually uses (ids normalised the same
|
|
2197
|
+
# way costs are), so the page can lead with those
|
|
2198
|
+
"used": sorted({self.pricing.normalize(r["model"]) for r in self.q(
|
|
2199
|
+
"SELECT DISTINCT model FROM requests")} & set(self.pricing.models))},
|
|
1903
2200
|
"settings": self.settings,
|
|
1904
2201
|
"version": _version(),
|
|
1905
2202
|
"unavailable_label": UNAVAILABLE,
|