claude-finops 0.14.0 → 0.15.1
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 +38 -1
- package/config/model_compare.json +4 -2
- package/config/pricing.json +17 -6
- package/config/settings.json +8 -0
- package/finops/actions.py +54 -27
- package/finops/agents.py +13 -39
- package/finops/analytics.py +362 -141
- package/finops/api.py +69 -12
- package/finops/classify.py +6 -0
- package/finops/cloud.py +8 -4
- package/finops/diagnose.py +18 -5
- package/finops/etl.py +171 -7
- package/finops/jev.py +3 -1
- package/finops/localtime.py +53 -0
- package/finops/pricing.py +27 -0
- package/finops/procs.py +9 -4
- package/finops/subagents.py +358 -0
- package/package.json +1 -1
- package/run.py +2 -0
- package/web/app.js +216 -65
- package/web/charts.js +14 -6
- package/web/styles.css +2 -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
|
|
@@ -164,6 +165,13 @@ def _d(s):
|
|
|
164
165
|
return datetime.strptime(s, "%Y-%m-%d").date()
|
|
165
166
|
|
|
166
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
|
+
|
|
167
175
|
def _cumsum(values):
|
|
168
176
|
total = 0.0
|
|
169
177
|
for v in values:
|
|
@@ -201,7 +209,15 @@ class Analytics:
|
|
|
201
209
|
_today = None # tests set this; production uses the clock
|
|
202
210
|
|
|
203
211
|
def today(self):
|
|
204
|
-
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
|
|
205
221
|
|
|
206
222
|
@property
|
|
207
223
|
def db(self):
|
|
@@ -337,6 +353,11 @@ class Analytics:
|
|
|
337
353
|
p).get("n", 0)
|
|
338
354
|
tot["tool_calls"] = self.one(
|
|
339
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)
|
|
340
361
|
|
|
341
362
|
bp = self.billing_period()
|
|
342
363
|
today = bp["today"]
|
|
@@ -362,8 +383,12 @@ class Analytics:
|
|
|
362
383
|
|
|
363
384
|
# ---------------- burn rate & limits ----------------
|
|
364
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)
|
|
365
389
|
w, p = self.where(f)
|
|
366
390
|
bp = self.billing_period()
|
|
391
|
+
today, synced = self.today(), self.data_end()
|
|
367
392
|
rows = self.q(f"""SELECT r.day, SUM(r.est_cost_usd) cost, SUM(r.billable_tokens) tokens,
|
|
368
393
|
COUNT(*) requests FROM requests r
|
|
369
394
|
WHERE {w} AND r.day >= ? AND r.day <= ? GROUP BY 1 ORDER BY 1""",
|
|
@@ -371,23 +396,37 @@ class Analytics:
|
|
|
371
396
|
used_cost = sum(r["cost"] for r in rows)
|
|
372
397
|
used_tokens = sum(r["tokens"] for r in rows)
|
|
373
398
|
used_req = sum(r["requests"] for r in rows)
|
|
374
|
-
|
|
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)
|
|
375
403
|
daily_avg = used_cost / elapsed
|
|
376
404
|
|
|
377
405
|
# Trailing rates are measured over the last N CALENDAR days, not only the
|
|
378
406
|
# slice inside the billing period — early in a period that slice is too short
|
|
379
407
|
# to be a rate. Idle days count as zero, because these rates get multiplied by
|
|
380
408
|
# calendar days remaining. This keeps burn and forecast on one methodology.
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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)
|
|
384
413
|
avg7 = (sum(r["cost"] for r in last7) / len(last7)) if last7 else 0.0
|
|
385
414
|
tok_avg7 = (sum(r["tokens"] for r in last7) / len(last7)) if last7 else 0.0
|
|
386
415
|
# the projection rate matches Analytics.forecast()'s "expected" scenario
|
|
387
416
|
burn = (sum(r["cost"] for r in last14) / len(last14)) if last14 else daily_avg
|
|
388
417
|
tok_burn = (sum(r["tokens"] for r in last14) / len(last14)) if last14 else 0.0
|
|
389
|
-
|
|
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
|
|
390
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
|
|
391
430
|
|
|
392
431
|
lim = self.settings["limits"]
|
|
393
432
|
out = {
|
|
@@ -399,7 +438,8 @@ class Analytics:
|
|
|
399
438
|
"burn_rate_cost_per_day": burn,
|
|
400
439
|
"burn_rate_window_days": len(last14),
|
|
401
440
|
"projected_period_cost": projected,
|
|
402
|
-
"projected_period_tokens": used_tokens + tok_burn *
|
|
441
|
+
"projected_period_tokens": used_tokens + tok_burn * ahead,
|
|
442
|
+
"synced_through": synced.isoformat(), "unread_days": unread,
|
|
403
443
|
"forecast_basis": "forecast",
|
|
404
444
|
"forecast_note": ("Projection uses the %d-calendar-day mean daily spend, idle days "
|
|
405
445
|
"included as zero — the same rate as the "
|
|
@@ -410,7 +450,7 @@ class Analytics:
|
|
|
410
450
|
for key, used_val, rate, label in (
|
|
411
451
|
("monthly_cost_allowance_usd", used_cost, burn, "cost"),
|
|
412
452
|
("monthly_token_allowance", used_tokens, (tok_burn or tok_daily), "tokens"),
|
|
413
|
-
("monthly_request_allowance", used_req,
|
|
453
|
+
("monthly_request_allowance", used_req, req_burn, "requests"),
|
|
414
454
|
):
|
|
415
455
|
allowance = lim.get(key)
|
|
416
456
|
if not allowance:
|
|
@@ -419,7 +459,7 @@ class Analytics:
|
|
|
419
459
|
remaining = allowance - used_val
|
|
420
460
|
pct = 100.0 * used_val / allowance
|
|
421
461
|
days_left = (remaining / rate) if rate > 0 and remaining > 0 else None
|
|
422
|
-
proj = used_val + rate *
|
|
462
|
+
proj = used_val + rate * ahead
|
|
423
463
|
out["allowances"][label] = {
|
|
424
464
|
"configured": True, "allowance": allowance, "used": used_val,
|
|
425
465
|
"remaining": remaining, "used_pct": round(pct, 1),
|
|
@@ -446,29 +486,64 @@ class Analytics:
|
|
|
446
486
|
# ---------------- timeline ----------------
|
|
447
487
|
def timeline(self, f=None, grain="day"):
|
|
448
488
|
w, p = self.where(f)
|
|
449
|
-
col = "r.day" if grain == "day" else "
|
|
450
|
-
|
|
489
|
+
col = "r.day" if grain == "day" else self._hour_bucket("r.day", "r.hour", "r.ts")
|
|
490
|
+
rows = self.q(f"""
|
|
451
491
|
SELECT {col} bucket, COUNT(*) requests,
|
|
452
492
|
COUNT(DISTINCT r.session_id) sessions,
|
|
453
|
-
COUNT(DISTINCT r.prompt_id) prompts,
|
|
454
493
|
SUM(r.input_tokens) input_tokens, SUM(r.output_tokens) output_tokens,
|
|
455
494
|
SUM(r.cache_read_tokens) cache_read_tokens,
|
|
456
495
|
SUM(r.cache_write_tokens) cache_write_tokens,
|
|
457
496
|
SUM(r.billable_tokens) tokens, SUM(r.est_cost_usd) cost,
|
|
458
497
|
AVG(r.context_tokens) avg_context
|
|
459
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
|
|
460
532
|
|
|
461
533
|
def heatmap(self, f=None):
|
|
462
|
-
"""Spend by weekday x hour, in
|
|
534
|
+
"""Spend by weekday x hour, in your local time (Monday first).
|
|
463
535
|
|
|
464
536
|
Transcripts stamp UTC; bucketing on that would put a 10am IST session at
|
|
465
|
-
4am.
|
|
466
|
-
|
|
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.
|
|
467
540
|
"""
|
|
468
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')"
|
|
469
544
|
rows = self.q(f"""
|
|
470
|
-
SELECT CAST(
|
|
471
|
-
CAST(
|
|
545
|
+
SELECT CAST({wd} AS INTEGER) wd,
|
|
546
|
+
CAST({hr} AS INTEGER) hr,
|
|
472
547
|
COALESCE(SUM(r.est_cost_usd),0) cost, COALESCE(SUM(r.billable_tokens),0) tokens,
|
|
473
548
|
COUNT(*) requests
|
|
474
549
|
FROM requests r WHERE {w} AND r.ts <> '' GROUP BY 1, 2""", p)
|
|
@@ -480,7 +555,7 @@ class Analytics:
|
|
|
480
555
|
c = grid[((r["wd"] + 6) % 7, r["hr"])]
|
|
481
556
|
c["cost"], c["tokens"], c["requests"] = r["cost"], r["tokens"], r["requests"]
|
|
482
557
|
return {"cells": [grid[(d, h)] for d in range(7) for h in range(24)],
|
|
483
|
-
"tz": time.strftime("%Z"), "cost_basis": "estimated"}
|
|
558
|
+
"tz": os.environ.get("CLAUDE_FINOPS_TZ") or time.strftime("%Z"), "cost_basis": "estimated"}
|
|
484
559
|
|
|
485
560
|
# ---------------- models ----------------
|
|
486
561
|
def models(self, f=None):
|
|
@@ -507,9 +582,12 @@ class Analytics:
|
|
|
507
582
|
COUNT(*) n, AVG(r.context_tokens) avg_ctx
|
|
508
583
|
FROM requests r WHERE {w}
|
|
509
584
|
GROUP BY r.model, r.priced_as, r.unpriced_long_context""", p):
|
|
510
|
-
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})
|
|
511
586
|
win = self.pricing.context_window(v["priced_as"] or v["model"])
|
|
512
|
-
if
|
|
587
|
+
if not win:
|
|
588
|
+
u["nowin"] += v["n"] # no known window: neither "over" nor a utilisation
|
|
589
|
+
continue
|
|
590
|
+
if v["u"]:
|
|
513
591
|
u["over"] += v["n"]
|
|
514
592
|
continue
|
|
515
593
|
u["num"] += v["n"] * 100.0 * (v["avg_ctx"] or 0) / win
|
|
@@ -521,6 +599,9 @@ class Analytics:
|
|
|
521
599
|
r["tier"] = self.pricing.tier(r["model"])
|
|
522
600
|
r["context_window"] = self.pricing.context_window(r["model"])
|
|
523
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"))
|
|
524
605
|
r["cost_pct"] = round(100.0 * r["cost"] / tc, 1)
|
|
525
606
|
r["token_pct"] = round(100.0 * r["tokens"] / tt, 1)
|
|
526
607
|
r["cost_per_1k_output"] = (1000.0 * r["cost"] / r["output_tokens"]) if r["output_tokens"] else None
|
|
@@ -530,16 +611,21 @@ class Analytics:
|
|
|
530
611
|
u = util.get(r["model"], {})
|
|
531
612
|
r["utilization_pct"] = round(u["num"] / u["den"], 1) if u.get("den") else None
|
|
532
613
|
r["over_window_requests"] = u.get("over", 0)
|
|
614
|
+
r["unknown_window_requests"] = u.get("nowin", 0)
|
|
533
615
|
r["long_context_requests"] = u.get("long", 0)
|
|
534
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)]
|
|
535
621
|
superlatives = {}
|
|
536
622
|
if priced:
|
|
537
623
|
superlatives = {
|
|
538
624
|
"most_expensive": max(priced, key=lambda r: r["cost"])["model"],
|
|
539
625
|
"most_used": max(priced, key=lambda r: r["requests"])["model"],
|
|
540
|
-
"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"],
|
|
541
627
|
"best_cost_per_output": min(
|
|
542
|
-
[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"]],
|
|
543
629
|
key=lambda r: r["cost_per_1k_output"], default={}).get("model"),
|
|
544
630
|
}
|
|
545
631
|
return {"rows": rows, "superlatives": superlatives, "basis": "estimated"}
|
|
@@ -556,12 +642,15 @@ class Analytics:
|
|
|
556
642
|
SUM(r.est_cost_usd) cost, GROUP_CONCAT(DISTINCT r.model) models
|
|
557
643
|
FROM requests r JOIN projects pr ON pr.id = r.project_id
|
|
558
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)}
|
|
559
650
|
for r in rows:
|
|
560
651
|
r["avg_cost_per_session"] = r["cost"] / r["sessions"] if r["sessions"] else 0
|
|
561
652
|
r["avg_cost_per_prompt"] = r["cost"] / r["prompts"] if r["prompts"] else None
|
|
562
|
-
r["files_touched"] =
|
|
563
|
-
"SELECT COUNT(DISTINCT path) n FROM files_touched WHERE project_id=?",
|
|
564
|
-
(r["project_id"],))["n"]
|
|
653
|
+
r["files_touched"] = files.get(r["project_id"], 0)
|
|
565
654
|
b = self.settings["budgets"].get("per_project_usd", {}).get(r["name"])
|
|
566
655
|
r["budget_usd"] = b
|
|
567
656
|
r["budget_used_pct"] = round(100.0 * r["cost"] / b, 1) if b else None
|
|
@@ -615,13 +704,17 @@ class Analytics:
|
|
|
615
704
|
f"JOIN sessions s ON s.id = r.session_id "
|
|
616
705
|
f"JOIN projects pr ON pr.id = s.project_id WHERE {w}", p)["n"]
|
|
617
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
|
+
|
|
618
715
|
def prompts_total(self, f=None, search=None):
|
|
619
716
|
w, p = self.where(f)
|
|
620
|
-
extra, ep =
|
|
621
|
-
if search:
|
|
622
|
-
extra = (" AND r.prompt_id IN (SELECT id FROM prompts pr WHERE pr.text LIKE ? "
|
|
623
|
-
"OR pr.session_id LIKE ? OR pr.category LIKE ?)")
|
|
624
|
-
ep = [f"%{search}%"] * 3
|
|
717
|
+
extra, ep = self._prompt_search(search)
|
|
625
718
|
return self.one(f"SELECT COUNT(DISTINCT r.prompt_id) n FROM requests r "
|
|
626
719
|
f"WHERE {w}{extra} AND r.prompt_id IS NOT NULL", p + ep)["n"]
|
|
627
720
|
|
|
@@ -630,11 +723,9 @@ class Analytics:
|
|
|
630
723
|
ob = {"cost": "pcost DESC", "tokens": "ptokens DESC", "recent": "pr.ts DESC",
|
|
631
724
|
"cheapest": "pcost ASC", "efficiency": "efficiency DESC",
|
|
632
725
|
"length": "pr.char_len DESC"}.get(order, "pcost DESC")
|
|
633
|
-
extra, ep =
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
"OR pr.session_id LIKE ? OR pr.category LIKE ?)")
|
|
637
|
-
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 ""
|
|
638
729
|
# Aggregate requests per prompt first, rank, and only then join the (large) prompt
|
|
639
730
|
# text for the page being returned. Grouping with the text attached was ~2s a call.
|
|
640
731
|
if order in ("recent", "length"):
|
|
@@ -653,7 +744,7 @@ class Analytics:
|
|
|
653
744
|
GROUP_CONCAT(DISTINCT r.model) models,
|
|
654
745
|
(CAST(SUM(r.output_tokens) AS REAL) / MAX(SUM(r.billable_tokens),1)) efficiency
|
|
655
746
|
FROM requests r WHERE {w}{extra} AND r.prompt_id IS NOT NULL
|
|
656
|
-
GROUP BY r.prompt_id {inner_ob} {inner_lim})
|
|
747
|
+
GROUP BY r.prompt_id {having} {inner_ob} {inner_lim})
|
|
657
748
|
SELECT pr.id prompt_id, pr.uuid, pr.ts, pr.day, pr.text, pr.char_len, pr.word_len,
|
|
658
749
|
pr.category, pr.category_confidence, pr.category_evidence, pr.source,
|
|
659
750
|
pr.session_id, proj.name project, proj.id project_id, s.title session_title,
|
|
@@ -682,12 +773,23 @@ class Analytics:
|
|
|
682
773
|
r["category_evidence"] = []
|
|
683
774
|
return rows
|
|
684
775
|
|
|
685
|
-
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):
|
|
686
787
|
p = self.one("""SELECT pr.*, proj.name project, s.title session_title, s.git_branch
|
|
687
788
|
FROM prompts pr JOIN projects proj ON proj.id=pr.project_id
|
|
688
789
|
LEFT JOIN sessions s ON s.id=pr.session_id WHERE pr.id=?""", (pid,))
|
|
689
790
|
if not p:
|
|
690
791
|
return {"error": "not found"}
|
|
792
|
+
p["in_range"] = self._in_range(f, "prompt_id", pid)
|
|
691
793
|
p["requests"] = self.q(
|
|
692
794
|
"SELECT ts, model, effort, input_tokens, output_tokens, thinking_tokens,"
|
|
693
795
|
" cache_read_tokens, cache_write_tokens, billable_tokens, context_tokens,"
|
|
@@ -708,11 +810,12 @@ class Analytics:
|
|
|
708
810
|
p["category_evidence"] = []
|
|
709
811
|
return p
|
|
710
812
|
|
|
711
|
-
def session_detail(self, sid):
|
|
813
|
+
def session_detail(self, sid, f=None):
|
|
712
814
|
s = self.one("""SELECT s.*, pr.name project FROM sessions s
|
|
713
815
|
JOIN projects pr ON pr.id=s.project_id WHERE s.id=?""", (sid,))
|
|
714
816
|
if not s:
|
|
715
817
|
return {"error": "not found"}
|
|
818
|
+
s["in_range"] = self._in_range(f, "session_id", sid)
|
|
716
819
|
s["prompts"] = self.q("""
|
|
717
820
|
SELECT pr.id prompt_id, pr.ts, pr.category, substr(pr.text,1,220) preview,
|
|
718
821
|
pr.char_len, pr.billable_tokens ptokens, pr.est_cost_usd pcost,
|
|
@@ -740,8 +843,16 @@ class Analytics:
|
|
|
740
843
|
AVG(pr.char_len) avg_prompt_chars
|
|
741
844
|
FROM prompts pr JOIN requests r ON r.prompt_id = pr.id
|
|
742
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)}
|
|
743
852
|
tc = sum(r["cost"] for r in rows) or 1
|
|
744
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")
|
|
745
856
|
r["cost_pct"] = round(100.0 * r["cost"] / tc, 1)
|
|
746
857
|
r["cost_per_prompt"] = r["cost"] / r["prompts"] if r["prompts"] else 0
|
|
747
858
|
return {"rows": rows, "basis": "estimated",
|
|
@@ -849,16 +960,17 @@ class Analytics:
|
|
|
849
960
|
WHERE s.id IN ({ph})""", ids)}
|
|
850
961
|
|
|
851
962
|
def downsample(traj):
|
|
963
|
+
"""Evenly spaced points, each with its real 1-based request number."""
|
|
852
964
|
n = len(traj)
|
|
853
|
-
if n <= trajectory_points
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
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]
|
|
857
968
|
|
|
858
969
|
out_sessions = []
|
|
859
970
|
for s in ranked:
|
|
860
971
|
m = meta.get(s["session_id"], {})
|
|
861
|
-
traj = downsample(s["traj"])
|
|
972
|
+
traj, traj_idx = downsample(s["traj"])
|
|
973
|
+
cum = list(_cumsum(cost for _, cost in s["traj"]))
|
|
862
974
|
out_sessions.append({
|
|
863
975
|
"session_id": s["session_id"], "title": m.get("title"), "project": m.get("project"),
|
|
864
976
|
"requests": s["requests"], "cost_usd": s["cost_usd"], "max_context": s["max_context"],
|
|
@@ -870,7 +982,8 @@ class Analytics:
|
|
|
870
982
|
"cost_after_pct": {str(t): (round(100.0 * s["cost_after"][t] / s["cost_usd"], 1)
|
|
871
983
|
if s["cost_usd"] else 0.0) for t in thresholds},
|
|
872
984
|
"context_trajectory": [c for c, _ in traj],
|
|
873
|
-
"
|
|
985
|
+
"trajectory_index": traj_idx,
|
|
986
|
+
"cumulative_cost": [round(cum[i - 1], 4) for i in traj_idx],
|
|
874
987
|
"basis": "actual",
|
|
875
988
|
})
|
|
876
989
|
return {
|
|
@@ -961,8 +1074,9 @@ class Analytics:
|
|
|
961
1074
|
% (sum(r["n"] for r in unpriced), ", ".join(sorted({r["model"] for r in unpriced})))
|
|
962
1075
|
if unpriced else ""),
|
|
963
1076
|
"unknown_message": (
|
|
964
|
-
"%d requests used a claude-* model with no entry in pricing.json
|
|
965
|
-
"
|
|
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."
|
|
966
1080
|
% (unknown_requests, ", ".join(unknown_models))
|
|
967
1081
|
if unknown_rows else ""),
|
|
968
1082
|
"basis": "estimated",
|
|
@@ -1070,6 +1184,8 @@ class Analytics:
|
|
|
1070
1184
|
"reads": t["cr"], "writes": t["cw"],
|
|
1071
1185
|
"cost_split": cache_cost,
|
|
1072
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,
|
|
1073
1189
|
"cost_with_cache": t["cost"], "cost_without_cache": t["cost_nc"],
|
|
1074
1190
|
# Named for what it is. This used to be "estimated_savings_usd", and the
|
|
1075
1191
|
# UI called it a saving; it is the gap to a run that never happened.
|
|
@@ -1111,14 +1227,28 @@ class Analytics:
|
|
|
1111
1227
|
WHERE {w} GROUP BY s.id HAVING MAX(r.context_tokens) >= ?
|
|
1112
1228
|
ORDER BY cost DESC LIMIT 20""", p + [thr])
|
|
1113
1229
|
agg = self.one(f"SELECT AVG(r.context_tokens) a, MAX(r.context_tokens) m FROM requests r WHERE {w}", p)
|
|
1114
|
-
|
|
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
|
|
1115
1244
|
return {
|
|
1116
1245
|
"buckets": buckets, "avg_context": agg["a"], "max_context": agg["m"],
|
|
1117
1246
|
"threshold": thr,
|
|
1118
1247
|
"large_context_requests": big["n"], "large_context_cost": big["c"],
|
|
1119
1248
|
"large_context_cost_pct": round(100.0 * big["c"] / total_cost, 1),
|
|
1120
1249
|
"heavy_sessions": heavy_sessions,
|
|
1121
|
-
"typical_context_window":
|
|
1250
|
+
"typical_context_window": typical_window,
|
|
1251
|
+
"context_utilization_pct": util_pct,
|
|
1122
1252
|
"basis": "actual token counts, estimated cost",
|
|
1123
1253
|
}
|
|
1124
1254
|
|
|
@@ -1154,41 +1284,74 @@ class Analytics:
|
|
|
1154
1284
|
"affected": {key: [e[key] for e in evidence if e.get(key)]},
|
|
1155
1285
|
"recommended_action": action, "basis": "estimated"})
|
|
1156
1286
|
|
|
1157
|
-
#
|
|
1158
|
-
#
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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.
|
|
1164
1295
|
budget_chars = rules["long_prompt_chars"]
|
|
1165
|
-
for
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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")
|
|
1174
1316
|
|
|
1175
1317
|
# 2. duplicate prompts — excess is the cost of the repeats, not the first ask,
|
|
1176
1318
|
# counted only within a single session so cross-session coincidences don't count.
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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"])
|
|
1187
1349
|
if dups:
|
|
1188
|
-
add("high", "duplicate_prompts",
|
|
1350
|
+
add("high", "duplicate_prompts",
|
|
1351
|
+
f"{len(dups)} prompts repeated within {rules.get('duplicate_window_minutes', 60)} minutes",
|
|
1189
1352
|
"The same request was sent again, re-paying for context each time.",
|
|
1190
1353
|
dups, "Reuse the earlier answer, or capture the recurring request as a slash command.",
|
|
1191
|
-
"prompt_id", "cost of the
|
|
1354
|
+
"prompt_id", "cost of the repeats, excluding the first ask of each run")
|
|
1192
1355
|
|
|
1193
1356
|
# 3. low-yield sessions — excess is what the session cost ABOVE what the same
|
|
1194
1357
|
# output would have cost at your own median session efficiency.
|
|
@@ -1210,7 +1373,7 @@ class Analytics:
|
|
|
1210
1373
|
proj.name project FROM sessions s JOIN projects proj ON proj.id=s.project_id
|
|
1211
1374
|
WHERE {sfilter} AND s.billable_tokens > ?
|
|
1212
1375
|
AND (CAST(s.output_tokens AS REAL)/MAX(s.billable_tokens,1)) < ?
|
|
1213
|
-
ORDER BY cost DESC
|
|
1376
|
+
ORDER BY cost DESC""",
|
|
1214
1377
|
p + [rules["huge_session_tokens"], cutoff])
|
|
1215
1378
|
for r in low:
|
|
1216
1379
|
r["excess"] = 0.0
|
|
@@ -1248,7 +1411,7 @@ class Analytics:
|
|
|
1248
1411
|
AND pr.output_tokens < ? AND pr.tool_calls = 0 AND pr.est_cost_usd > 0
|
|
1249
1412
|
AND EXISTS (SELECT 1 FROM requests r2 WHERE r2.prompt_id=pr.id
|
|
1250
1413
|
AND r2.model IN ({ph}))
|
|
1251
|
-
ORDER BY cost DESC
|
|
1414
|
+
ORDER BY cost DESC""",
|
|
1252
1415
|
p + [rules["simple_task_output_tokens"]] + frontier)
|
|
1253
1416
|
for r in small:
|
|
1254
1417
|
r["excess"] = 0.0
|
|
@@ -1257,7 +1420,9 @@ class Analytics:
|
|
|
1257
1420
|
f"{len(small)} frontier-model prompts produced under "
|
|
1258
1421
|
f"{rules['simple_task_output_tokens']} output tokens",
|
|
1259
1422
|
"Short, simple turns running on the most expensive model tier.",
|
|
1260
|
-
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.",
|
|
1261
1426
|
"prompt_id", "none claimed")
|
|
1262
1427
|
|
|
1263
1428
|
# 5. tool loops — excess is the share of the loop beyond the threshold.
|
|
@@ -1265,11 +1430,11 @@ class Analytics:
|
|
|
1265
1430
|
loops = self.q(f"""SELECT pr.id prompt_id, substr(pr.text,1,160) preview, pr.session_id,
|
|
1266
1431
|
pr.tool_calls tools, pr.est_cost_usd cost, pr.billable_tokens tokens
|
|
1267
1432
|
FROM prompts pr WHERE {pfilter} AND pr.tool_calls > ?
|
|
1268
|
-
ORDER BY cost DESC
|
|
1433
|
+
ORDER BY cost DESC""", p + [loop_calls])
|
|
1269
1434
|
for r in loops:
|
|
1270
1435
|
r["excess"] = 0.0
|
|
1271
1436
|
if loops:
|
|
1272
|
-
add("medium", "tool_loops", f"{len(loops)} prompts
|
|
1437
|
+
add("medium", "tool_loops", f"{len(loops)} prompts made more than {loop_calls} tool calls",
|
|
1273
1438
|
"Long agentic loops re-send the whole conversation each step, so cost grows super-linearly.",
|
|
1274
1439
|
loops, "Split the task, or give more precise instructions up front.", "prompt_id",
|
|
1275
1440
|
"none claimed")
|
|
@@ -1296,7 +1461,7 @@ class Analytics:
|
|
|
1296
1461
|
if net > 0:
|
|
1297
1462
|
r["excess"] = net
|
|
1298
1463
|
flagged.append(r)
|
|
1299
|
-
poor = flagged
|
|
1464
|
+
poor = flagged
|
|
1300
1465
|
if poor:
|
|
1301
1466
|
add("medium", "poor_cache_reuse", f"{len(poor)} sessions wrote cache they barely reused",
|
|
1302
1467
|
"Cache writes cost more than plain input; they only pay off when read back repeatedly.",
|
|
@@ -1309,7 +1474,7 @@ class Analytics:
|
|
|
1309
1474
|
s.request_count requests, s.est_cost_usd cost
|
|
1310
1475
|
FROM sessions s JOIN projects proj ON proj.id=s.project_id
|
|
1311
1476
|
WHERE {sfilter} AND s.duration_s > ? AND s.request_count < 30
|
|
1312
|
-
ORDER BY s.duration_s DESC
|
|
1477
|
+
ORDER BY s.duration_s DESC""",
|
|
1313
1478
|
p + [rules["idle_gap_minutes"] * 60 * 4])
|
|
1314
1479
|
for r in idle:
|
|
1315
1480
|
r["excess"] = 0.0
|
|
@@ -1357,7 +1522,7 @@ class Analytics:
|
|
|
1357
1522
|
len(e.get("affected_ids") or [1]), 1))
|
|
1358
1523
|
elif e.get("session_id"):
|
|
1359
1524
|
s_excess[e["session_id"]] = max(s_excess.get(e["session_id"], 0.0), ex)
|
|
1360
|
-
counted_sessions =
|
|
1525
|
+
counted_sessions = {k for k, v in s_excess.items() if v > 0}
|
|
1361
1526
|
outside = 0.0
|
|
1362
1527
|
if p_excess:
|
|
1363
1528
|
owner = {r["id"]: r["session_id"] for r in self.q(
|
|
@@ -1366,7 +1531,13 @@ class Analytics:
|
|
|
1366
1531
|
outside = sum(v for pid_, v in p_excess.items()
|
|
1367
1532
|
if owner.get(pid_) not in counted_sessions)
|
|
1368
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()}
|
|
1369
1539
|
return {"findings": findings,
|
|
1540
|
+
"high_severity_pct": round(min(100.0 * high / total, 100.0), 1) if total else 0,
|
|
1370
1541
|
"estimated_excess_usd": excess,
|
|
1371
1542
|
"excess_pct": round(100.0 * excess / total, 1) if total else 0,
|
|
1372
1543
|
"exposed_cost_usd": exposed,
|
|
@@ -1472,42 +1643,58 @@ class Analytics:
|
|
|
1472
1643
|
"estimated_savings_usd": None, "estimated_savings_pct": None,
|
|
1473
1644
|
"caveat": "No saving is claimed: what an uncached run would have cost is a "
|
|
1474
1645
|
"counterfactual, not money you avoided.",
|
|
1475
|
-
"basis": "
|
|
1646
|
+
"basis": "estimated",
|
|
1476
1647
|
})
|
|
1477
1648
|
|
|
1478
|
-
|
|
1479
|
-
|
|
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:
|
|
1480
1661
|
recs.append({
|
|
1481
1662
|
"type": "context_reduction", "confidence": "observed",
|
|
1482
|
-
"title": f"{
|
|
1483
|
-
f"{
|
|
1484
|
-
"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 "
|
|
1485
1666
|
f"on every turn. /compact or a fresh session resets it; how much "
|
|
1486
1667
|
f"that would have saved depends on what the work needed, and is "
|
|
1487
1668
|
f"not estimated here."),
|
|
1488
|
-
"scope": f"{
|
|
1489
|
-
"actual_cost_usd":
|
|
1669
|
+
"scope": f"{big['requests']:,} requests",
|
|
1670
|
+
"actual_cost_usd": big["cost_usd"],
|
|
1490
1671
|
"estimated_alternative_cost_usd": None,
|
|
1491
1672
|
"estimated_savings_usd": None, "estimated_savings_pct": None,
|
|
1492
1673
|
"caveat": "Observed share of spend. No reduction is assumed.",
|
|
1493
|
-
"basis": "
|
|
1674
|
+
"basis": "estimated",
|
|
1494
1675
|
})
|
|
1495
1676
|
recs.sort(key=lambda r: -(r.get("actual_cost_usd") or 0))
|
|
1496
1677
|
return {"recommendations": recs, "basis": "actual"}
|
|
1497
1678
|
|
|
1498
1679
|
# ---------------- forecast ----------------
|
|
1499
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)
|
|
1500
1684
|
w, p = self.where(f)
|
|
1501
1685
|
bp = self.billing_period()
|
|
1502
1686
|
rows = self.q(f"""SELECT r.day, SUM(r.est_cost_usd) cost, SUM(r.billable_tokens) tokens
|
|
1503
1687
|
FROM requests r WHERE {w} AND r.day <> '' GROUP BY 1 ORDER BY 1""", p)
|
|
1504
1688
|
if not rows:
|
|
1505
1689
|
return {"available": False, "message": "No usage in the selected range."}
|
|
1506
|
-
# calendar days, idle days as zero, excluding
|
|
1507
|
-
# below is multiplied by calendar days remaining, so a per-active-day
|
|
1508
|
-
# would overstate every scenario and a partial
|
|
1509
|
-
|
|
1510
|
-
|
|
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
|
|
1511
1698
|
priced = [r["cost"] for r in recent]
|
|
1512
1699
|
sample_days = sum(1 for c in priced if c > 0)
|
|
1513
1700
|
mean = statistics.fmean(priced) if priced else 0.0
|
|
@@ -1515,14 +1702,15 @@ class Analytics:
|
|
|
1515
1702
|
in_period = [r for r in rows if bp["start"] <= r["day"] <= bp["end"]]
|
|
1516
1703
|
used = sum(r["cost"] for r in in_period)
|
|
1517
1704
|
used_tok = sum(r["tokens"] for r in in_period)
|
|
1518
|
-
left = bp["remaining_days"]
|
|
1705
|
+
left = bp["remaining_days"] + unread
|
|
1519
1706
|
insufficient = sample_days < 7
|
|
1520
1707
|
|
|
1521
1708
|
def band(rate, spread=0.0):
|
|
1522
1709
|
# spend on different days is treated as independent, so the spread of a
|
|
1523
1710
|
# sum over `left` days grows with sqrt(left), not left
|
|
1524
|
-
|
|
1525
|
-
|
|
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}
|
|
1526
1714
|
|
|
1527
1715
|
scenarios = {"expected": band(mean)}
|
|
1528
1716
|
if not insufficient:
|
|
@@ -1537,14 +1725,15 @@ class Analytics:
|
|
|
1537
1725
|
"available": True,
|
|
1538
1726
|
"method": ("mean of the last 14 complete calendar days (idle days as zero); "
|
|
1539
1727
|
"bands are ±1 sd × sqrt(days remaining)"),
|
|
1540
|
-
"sample_days": sample_days,
|
|
1728
|
+
"sample_days": sample_days, "window_days": len(recent),
|
|
1729
|
+
"synced_through": synced.isoformat(), "unread_days": unread,
|
|
1541
1730
|
"insufficient_history": insufficient,
|
|
1542
1731
|
"daily_mean": mean, "daily_stdev": sd,
|
|
1543
1732
|
"period_used": used, "period_used_tokens": used_tok,
|
|
1544
1733
|
"remaining_days": left,
|
|
1545
1734
|
"scenarios": scenarios,
|
|
1546
1735
|
"end_of_period_tokens": used_tok + tok_mean * left,
|
|
1547
|
-
"estimated_monthly_cost":
|
|
1736
|
+
"estimated_monthly_cost": mean * 30,
|
|
1548
1737
|
"basis": "forecast",
|
|
1549
1738
|
}
|
|
1550
1739
|
allowance = self.settings["limits"].get("monthly_cost_allowance_usd")
|
|
@@ -1603,36 +1792,43 @@ class Analytics:
|
|
|
1603
1792
|
out["lines"].append(self._session_budget_line(f, bp, line))
|
|
1604
1793
|
else:
|
|
1605
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 []})
|
|
1606
1797
|
for proj, bud in (b.get("per_project_usd") or {}).items():
|
|
1607
|
-
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
|
|
1608
1799
|
JOIN projects pr ON pr.id=r.project_id
|
|
1609
|
-
WHERE pr.name=? AND r.day>=? AND r.day<=?""",
|
|
1610
|
-
|
|
1800
|
+
WHERE {wa} AND pr.name=? AND r.day>=? AND r.day<=?""",
|
|
1801
|
+
pa + [proj, bp["start"], bp["end"]])["c"]
|
|
1611
1802
|
out["lines"].append(line(f"Project: {proj}", bud, act, None))
|
|
1612
1803
|
for model, bud in (b.get("per_model_usd") or {}).items():
|
|
1613
|
-
act = self.one("""SELECT COALESCE(SUM(est_cost_usd),0) c FROM requests
|
|
1614
|
-
WHERE model=? AND day>=? AND day<=?""",
|
|
1615
|
-
|
|
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"]
|
|
1616
1807
|
out["lines"].append(line(f"Model: {self.pricing.display_name(model)}", bud, act, None))
|
|
1617
1808
|
return out
|
|
1618
1809
|
|
|
1619
1810
|
def budget_suggestions(self, f=None):
|
|
1620
1811
|
"""Figures the Budgets form offers as suggestions: recent spend and session sizes.
|
|
1621
1812
|
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
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.
|
|
1625
1818
|
"""
|
|
1626
|
-
w, p = self.where(f)
|
|
1627
|
-
|
|
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()
|
|
1628
1822
|
days = [r["c"] for r in self.q(
|
|
1629
1823
|
f"SELECT r.day, SUM(r.est_cost_usd) c FROM requests r WHERE {w} AND r.day >= ? "
|
|
1630
|
-
f"GROUP BY r.day", p + [start])]
|
|
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
|
|
1631
1826
|
tot = self.one(f"SELECT COALESCE(SUM(r.est_cost_usd),0) c, COALESCE(SUM(r.billable_tokens),0) t "
|
|
1632
|
-
f"FROM requests r WHERE {w} AND r.day >= ?", p + [start])
|
|
1827
|
+
f"FROM requests r WHERE {w} AND r.day >= ? AND r.day <= ?", p + [start, end])
|
|
1633
1828
|
sizes = [r["t"] for r in self.q(
|
|
1634
1829
|
"SELECT billable_tokens t FROM sessions WHERE agent='claude' AND billable_tokens > 0 "
|
|
1635
|
-
"
|
|
1830
|
+
"AND id IN (SELECT DISTINCT session_id FROM requests WHERE day >= ? AND day <= ?) "
|
|
1831
|
+
"ORDER BY t", (start, end))]
|
|
1636
1832
|
if len(sizes) > 2000:
|
|
1637
1833
|
step = len(sizes) / 2000.0
|
|
1638
1834
|
sizes = [sizes[int(i * step)] for i in range(2000)]
|
|
@@ -1674,8 +1870,11 @@ class Analytics:
|
|
|
1674
1870
|
w, p = self.where(f)
|
|
1675
1871
|
cfg = self.settings["anomaly"]
|
|
1676
1872
|
found = []
|
|
1677
|
-
|
|
1678
|
-
|
|
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())]
|
|
1679
1878
|
priced = [d for d in series if d["cost"] > 0]
|
|
1680
1879
|
if len(priced) >= 14:
|
|
1681
1880
|
# Median/MAD, not mean/stdev: unpriced $0 days from agents without pricing
|
|
@@ -1693,7 +1892,7 @@ class Analytics:
|
|
|
1693
1892
|
if score >= cfg.get("daily_robust_z", 3.5) and ratio >= cfg["daily_ratio"]:
|
|
1694
1893
|
found.append({
|
|
1695
1894
|
"severity": "high", "type": "daily_spike", "date": d["day"],
|
|
1696
|
-
"title": f"{d['day']} spend was {ratio:.1f}x your
|
|
1895
|
+
"title": f"{d['day']} spend was {ratio:.1f}x your typical (median) day",
|
|
1697
1896
|
"detail": f"${d['cost']:,.2f} vs a ${med:,.2f} median priced day (robust z={score:.1f}).",
|
|
1698
1897
|
"metric_value": d["cost"], "baseline": med, "ratio": round(ratio, 2),
|
|
1699
1898
|
"drilldown": {"filter": {"start": d["day"], "end": d["day"]}},
|
|
@@ -1731,7 +1930,7 @@ class Analytics:
|
|
|
1731
1930
|
})
|
|
1732
1931
|
# week-over-week model shift
|
|
1733
1932
|
if self.last_day:
|
|
1734
|
-
end =
|
|
1933
|
+
end = last_full # the last complete day you have data for, within the range
|
|
1735
1934
|
cur_s = (end - timedelta(days=6)).isoformat()
|
|
1736
1935
|
prev_s, prev_e = (end - timedelta(days=13)).isoformat(), (end - timedelta(days=7)).isoformat()
|
|
1737
1936
|
for m in self.q(f"SELECT DISTINCT r.model FROM requests r WHERE {w}", p):
|
|
@@ -1751,7 +1950,8 @@ class Analytics:
|
|
|
1751
1950
|
"drilldown": {"filter": {"models": [model], "start": cur_s}},
|
|
1752
1951
|
"basis": "estimated",
|
|
1753
1952
|
})
|
|
1754
|
-
|
|
1953
|
+
sev = {"high": 0, "medium": 1, "low": 2}
|
|
1954
|
+
found.sort(key=lambda x: (sev.get(x["severity"], 3), -x.get("ratio", 0)))
|
|
1755
1955
|
return {"anomalies": found[:25], "basis": "estimated"}
|
|
1756
1956
|
|
|
1757
1957
|
# ---------------- scorecard ----------------
|
|
@@ -1775,9 +1975,12 @@ class Analytics:
|
|
|
1775
1975
|
if margin is None:
|
|
1776
1976
|
dim("Cache break-even", 50, "No cache activity in range", 1.0)
|
|
1777
1977
|
else:
|
|
1978
|
+
ratio = eff["cache"].get("breakeven_ratio") or margin
|
|
1778
1979
|
dim("Cache break-even", 50 + margin * 50,
|
|
1779
|
-
f"Caching
|
|
1780
|
-
|
|
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)
|
|
1781
1984
|
|
|
1782
1985
|
ml = next((l for l in bud["lines"] if l["name"] == "Monthly spend"), None)
|
|
1783
1986
|
if ml and ml.get("configured"):
|
|
@@ -1796,7 +1999,7 @@ class Analytics:
|
|
|
1796
1999
|
return {
|
|
1797
2000
|
"score": total,
|
|
1798
2001
|
"dimensions": dims,
|
|
1799
|
-
"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],
|
|
1800
2003
|
"needs_attention": [f"{d['name']}: {d['detail']}" for d in weak if d["score"] < 70],
|
|
1801
2004
|
"biggest_opportunity": (
|
|
1802
2005
|
{"title": top["title"], "detail": top["detail"],
|
|
@@ -1818,15 +2021,19 @@ class Analytics:
|
|
|
1818
2021
|
ov = self.overview(f)
|
|
1819
2022
|
|
|
1820
2023
|
for a in anos["anomalies"][:2]:
|
|
1821
|
-
actions.append({"priority":
|
|
2024
|
+
actions.append({"priority": {"high": 1, "medium": 2}.get(a["severity"], 3),
|
|
2025
|
+
"kind": "anomaly", "text": a["title"],
|
|
1822
2026
|
"detail": a["detail"], "drilldown": a.get("drilldown"),
|
|
1823
2027
|
"basis": "estimated"})
|
|
1824
2028
|
for wf in wst["findings"][:2]:
|
|
1825
2029
|
actions.append({"priority": 2, "kind": "waste",
|
|
1826
2030
|
"text": wf["title"],
|
|
1827
|
-
"detail": f"{wf['detail']} ~${wf['est_excess_usd']:,.2f} estimated "
|
|
1828
|
-
|
|
1829
|
-
|
|
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"],
|
|
1830
2037
|
"basis": "estimated"})
|
|
1831
2038
|
for r in recs["recommendations"][:2]:
|
|
1832
2039
|
if r["type"] == "cache_working":
|
|
@@ -1870,14 +2077,14 @@ class Analytics:
|
|
|
1870
2077
|
WHERE {w} GROUP BY 1 ORDER BY 2 DESC""", p)
|
|
1871
2078
|
files = self.q(f"""SELECT ft.path, COUNT(*) touches, COUNT(DISTINCT ft.session_id) sessions,
|
|
1872
2079
|
GROUP_CONCAT(DISTINCT ft.op) ops
|
|
1873
|
-
FROM files_touched ft WHERE ft.
|
|
1874
|
-
(SELECT DISTINCT r.
|
|
2080
|
+
FROM files_touched ft WHERE ft.prompt_id IN
|
|
2081
|
+
(SELECT DISTINCT r.prompt_id FROM requests r WHERE {w})
|
|
1875
2082
|
GROUP BY 1 ORDER BY 2 DESC LIMIT 40""", p)
|
|
1876
2083
|
branches = self.q(f"""SELECT COALESCE(s.git_branch,'(none)') branch,
|
|
1877
2084
|
COUNT(DISTINCT s.id) sessions, SUM(r.est_cost_usd) cost,
|
|
1878
2085
|
SUM(r.billable_tokens) tokens
|
|
1879
2086
|
FROM requests r JOIN sessions s ON s.id=r.session_id
|
|
1880
|
-
WHERE {w} GROUP BY 1 ORDER BY cost DESC
|
|
2087
|
+
WHERE {w} GROUP BY 1 ORDER BY cost DESC""", p)
|
|
1881
2088
|
repos = [r for r in self.projects(f) if not r["is_sandbox"]]
|
|
1882
2089
|
return {
|
|
1883
2090
|
"tools": tools, "files": files, "branches": branches, "repositories": repos,
|
|
@@ -1897,32 +2104,41 @@ class Analytics:
|
|
|
1897
2104
|
|
|
1898
2105
|
# ---------------- global search ----------------
|
|
1899
2106
|
def search(self, term, limit=40):
|
|
1900
|
-
|
|
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):
|
|
1901
2117
|
return {
|
|
1902
2118
|
"term": term,
|
|
1903
2119
|
"prompts": self.q(
|
|
1904
2120
|
"SELECT id prompt_id, ts, category, session_id, substr(text,1,240) preview,"
|
|
1905
|
-
" est_cost_usd, billable_tokens FROM prompts WHERE text LIKE ?"
|
|
2121
|
+
" est_cost_usd, billable_tokens FROM prompts WHERE text LIKE ? ESCAPE '\\'"
|
|
1906
2122
|
" ORDER BY est_cost_usd DESC LIMIT ?", (like, limit)),
|
|
1907
2123
|
"sessions": self.q(
|
|
1908
2124
|
"SELECT s.id session_id, s.title, s.git_branch, pr.name project, s.started_at,"
|
|
1909
2125
|
" s.est_cost_usd, s.billable_tokens FROM sessions s"
|
|
1910
2126
|
" JOIN projects pr ON pr.id=s.project_id"
|
|
1911
|
-
" 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 '\\'"
|
|
1912
2128
|
" ORDER BY s.est_cost_usd DESC LIMIT ?", (like, like, like, limit)),
|
|
1913
2129
|
"projects": self.q(
|
|
1914
2130
|
"SELECT id project_id, name, path, slug FROM projects"
|
|
1915
|
-
" WHERE name LIKE ? OR path LIKE ? LIMIT ?", (like, like, limit)),
|
|
2131
|
+
" WHERE name LIKE ? ESCAPE '\\' OR path LIKE ? ESCAPE '\\' LIMIT ?", (like, like, limit)),
|
|
1916
2132
|
"models": self.q(
|
|
1917
2133
|
"SELECT model, COUNT(*) requests, SUM(est_cost_usd) cost FROM requests"
|
|
1918
|
-
" WHERE model LIKE ? GROUP BY 1", (like,)),
|
|
2134
|
+
" WHERE model LIKE ? ESCAPE '\\' GROUP BY 1", (like,)),
|
|
1919
2135
|
"tools": self.q(
|
|
1920
2136
|
"SELECT name, target, COUNT(*) n FROM tool_calls"
|
|
1921
|
-
" WHERE name LIKE ? OR target LIKE ? GROUP BY name, target"
|
|
2137
|
+
" WHERE name LIKE ? ESCAPE '\\' OR target LIKE ? ESCAPE '\\' GROUP BY name, target"
|
|
1922
2138
|
" ORDER BY n DESC LIMIT ?", (like, like, limit)),
|
|
1923
2139
|
"days": self.q(
|
|
1924
2140
|
"SELECT day, COUNT(*) requests, SUM(est_cost_usd) cost FROM requests"
|
|
1925
|
-
" WHERE day LIKE ? GROUP BY 1 ORDER BY 1", (like,)),
|
|
2141
|
+
" WHERE day LIKE ? ESCAPE '\\' GROUP BY 1 ORDER BY 1", (like,)),
|
|
1926
2142
|
}
|
|
1927
2143
|
|
|
1928
2144
|
# ---------------- filter option lists ----------------
|
|
@@ -1971,11 +2187,16 @@ class Analytics:
|
|
|
1971
2187
|
" FROM projects pr LEFT JOIN requests r ON r.project_id=pr.id"
|
|
1972
2188
|
" GROUP BY pr.id HAVING n>0 ORDER BY n DESC"),
|
|
1973
2189
|
"categories": self.q("SELECT category, COUNT(*) n FROM prompts GROUP BY 1 ORDER BY 2 DESC"),
|
|
1974
|
-
"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()},
|
|
1975
2192
|
"billing_period": self.billing_period(),
|
|
1976
2193
|
"meta": self.meta,
|
|
1977
2194
|
"pricing": {"updated": self.pricing.updated, "source": self.pricing.source,
|
|
1978
|
-
"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))},
|
|
1979
2200
|
"settings": self.settings,
|
|
1980
2201
|
"version": _version(),
|
|
1981
2202
|
"unavailable_label": UNAVAILABLE,
|