costtrace 0.1.0__py3-none-any.whl
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.
- CostTrace/__init__.py +60 -0
- CostTrace/analysis/__init__.py +42 -0
- CostTrace/analysis/aggregate.py +149 -0
- CostTrace/analysis/detect.py +245 -0
- CostTrace/analysis/judge.py +141 -0
- CostTrace/analysis/metrics.py +169 -0
- CostTrace/analysis/store.py +89 -0
- CostTrace/capture.py +119 -0
- CostTrace/capture_schema.py +244 -0
- CostTrace/cli.py +315 -0
- CostTrace/fetch.py +69 -0
- CostTrace/ingest.py +165 -0
- CostTrace/paths.py +49 -0
- CostTrace/pricing.py +208 -0
- CostTrace/pricing.yaml +49 -0
- CostTrace/providers/__init__.py +39 -0
- CostTrace/providers/base.py +68 -0
- CostTrace/providers/openai_agents.py +221 -0
- CostTrace/schema.py +31 -0
- CostTrace/trace.py +87 -0
- costtrace-0.1.0.dist-info/METADATA +422 -0
- costtrace-0.1.0.dist-info/RECORD +26 -0
- costtrace-0.1.0.dist-info/WHEEL +5 -0
- costtrace-0.1.0.dist-info/entry_points.txt +2 -0
- costtrace-0.1.0.dist-info/licenses/LICENSE +21 -0
- costtrace-0.1.0.dist-info/top_level.txt +1 -0
CostTrace/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""CostTrace — a deterministic cost-attribution profiler for LLM spend.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
from CostTrace import capture
|
|
6
|
+
capture(result, conversation_id) # the dev one-liner
|
|
7
|
+
|
|
8
|
+
from CostTrace import load_captures, fetch_captures
|
|
9
|
+
captures = load_captures("costtrace_captures.json") # validated ids
|
|
10
|
+
report = fetch_captures(captures) # validated traces
|
|
11
|
+
# report.conversations -> analyze; report.rejections -> what was dropped
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .capture import capture
|
|
17
|
+
from .capture_schema import (
|
|
18
|
+
CAPTURE_VERSION,
|
|
19
|
+
CaptureFile,
|
|
20
|
+
CaptureRecord,
|
|
21
|
+
CaptureSchemaError,
|
|
22
|
+
Provider,
|
|
23
|
+
)
|
|
24
|
+
from .fetch import fetch_captures
|
|
25
|
+
from .ingest import IngestError, load_captures, parse_captures
|
|
26
|
+
from .pricing import Pricer
|
|
27
|
+
from .providers import FetchReport, ProviderAuthError, Rejection, supported_providers
|
|
28
|
+
from .trace import CallTrace, ConversationTrace, Message, MessageKind
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
# capture
|
|
34
|
+
"capture",
|
|
35
|
+
# ingest
|
|
36
|
+
"load_captures",
|
|
37
|
+
"parse_captures",
|
|
38
|
+
"IngestError",
|
|
39
|
+
# fetch
|
|
40
|
+
"fetch_captures",
|
|
41
|
+
"FetchReport",
|
|
42
|
+
"Rejection",
|
|
43
|
+
"ProviderAuthError",
|
|
44
|
+
"supported_providers",
|
|
45
|
+
# schema
|
|
46
|
+
"CaptureFile",
|
|
47
|
+
"CaptureRecord",
|
|
48
|
+
"CaptureSchemaError",
|
|
49
|
+
"Provider",
|
|
50
|
+
"CAPTURE_VERSION",
|
|
51
|
+
# trace model
|
|
52
|
+
"ConversationTrace",
|
|
53
|
+
"CallTrace",
|
|
54
|
+
"Message",
|
|
55
|
+
"MessageKind",
|
|
56
|
+
# pricing
|
|
57
|
+
"Pricer",
|
|
58
|
+
"__version__",
|
|
59
|
+
]
|
|
60
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""CostTrace analysis — the brains.
|
|
2
|
+
|
|
3
|
+
Pipeline over validated ConversationTraces:
|
|
4
|
+
metrics -> per-session rows (cost, bucket split, prompt hash)
|
|
5
|
+
store -> durable JSONL history (append/upsert/prune)
|
|
6
|
+
aggregate -> overall spend + per-prompt-version views
|
|
7
|
+
detect -> what changed across every dimension (grounded findings)
|
|
8
|
+
judge -> LLM explains the likely cause in plain English
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .aggregate import (
|
|
12
|
+
OverallSummary,
|
|
13
|
+
ModelSummary,
|
|
14
|
+
PromptVersion,
|
|
15
|
+
group_by_prompt_version,
|
|
16
|
+
summarize_by_model,
|
|
17
|
+
summarize_overall,
|
|
18
|
+
)
|
|
19
|
+
from .detect import Finding, detect_all, detect_changes
|
|
20
|
+
from .judge import Judgment, build_digest, judge
|
|
21
|
+
from .metrics import SessionMetric, compute_session_metric, system_prompt_hash
|
|
22
|
+
from .store import load_metrics, upsert_metrics
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"SessionMetric",
|
|
26
|
+
"compute_session_metric",
|
|
27
|
+
"system_prompt_hash",
|
|
28
|
+
"load_metrics",
|
|
29
|
+
"upsert_metrics",
|
|
30
|
+
"OverallSummary",
|
|
31
|
+
"ModelSummary",
|
|
32
|
+
"PromptVersion",
|
|
33
|
+
"summarize_overall",
|
|
34
|
+
"summarize_by_model",
|
|
35
|
+
"group_by_prompt_version",
|
|
36
|
+
"Finding",
|
|
37
|
+
"detect_all",
|
|
38
|
+
"detect_changes",
|
|
39
|
+
"Judgment",
|
|
40
|
+
"build_digest",
|
|
41
|
+
"judge",
|
|
42
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Aggregation — roll SessionMetrics into higher-level views for the judge.
|
|
2
|
+
|
|
3
|
+
Two views:
|
|
4
|
+
* overall spend + where it goes (bucket totals across all sessions)
|
|
5
|
+
* per system-prompt version over time (the basis for regression detection)
|
|
6
|
+
|
|
7
|
+
Pure functions over already-computed metric rows — no fetching, no LLM.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Dict, List
|
|
14
|
+
|
|
15
|
+
from .metrics import SessionMetric
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class OverallSummary:
|
|
20
|
+
"""Where the whole bill goes, across every session in the store."""
|
|
21
|
+
|
|
22
|
+
num_sessions: int
|
|
23
|
+
total_cost: float
|
|
24
|
+
bucket_cost: Dict[str, float] # $ per bucket
|
|
25
|
+
bucket_pct: Dict[str, float] # % of total per bucket
|
|
26
|
+
avg_cost_per_turn: float
|
|
27
|
+
avg_cache_hit_rate: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class PromptVersion:
|
|
32
|
+
"""Stats for one system-prompt fingerprint over the sessions that used it."""
|
|
33
|
+
|
|
34
|
+
system_prompt_hash: str
|
|
35
|
+
system_prompt_text: str
|
|
36
|
+
num_sessions: int
|
|
37
|
+
total_cost: float
|
|
38
|
+
avg_cost_per_turn: float
|
|
39
|
+
avg_cache_hit_rate: float
|
|
40
|
+
first_seen: str = ""
|
|
41
|
+
last_seen: str = ""
|
|
42
|
+
_cpt_samples: List[float] = field(default_factory=list, repr=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ModelSummary:
|
|
47
|
+
"""Cost profile for one model, across the sessions that used it."""
|
|
48
|
+
|
|
49
|
+
model: str
|
|
50
|
+
num_sessions: int
|
|
51
|
+
total_turns: int
|
|
52
|
+
total_cost: float
|
|
53
|
+
avg_cost_per_turn: float
|
|
54
|
+
avg_cache_hit_rate: float
|
|
55
|
+
avg_output_tokens_per_turn: float
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def summarize_by_model(metrics: List[SessionMetric]) -> List[ModelSummary]:
|
|
59
|
+
"""Group sessions by model; return summaries sorted by cost/turn (desc)."""
|
|
60
|
+
groups: Dict[str, List[SessionMetric]] = {}
|
|
61
|
+
for m in metrics:
|
|
62
|
+
groups.setdefault(m.model, []).append(m)
|
|
63
|
+
|
|
64
|
+
out: List[ModelSummary] = []
|
|
65
|
+
for model, rows in groups.items():
|
|
66
|
+
n = len(rows)
|
|
67
|
+
total_turns = sum(r.num_turns for r in rows)
|
|
68
|
+
out.append(ModelSummary(
|
|
69
|
+
model=model,
|
|
70
|
+
num_sessions=n,
|
|
71
|
+
total_turns=total_turns,
|
|
72
|
+
total_cost=round(sum(r.total_cost for r in rows), 6),
|
|
73
|
+
avg_cost_per_turn=round(sum(r.cost_per_turn for r in rows) / n, 8),
|
|
74
|
+
avg_cache_hit_rate=round(sum(r.cache_hit_rate for r in rows) / n, 4),
|
|
75
|
+
avg_output_tokens_per_turn=round(
|
|
76
|
+
sum((r.output_tokens / r.num_turns) if r.num_turns else 0
|
|
77
|
+
for r in rows) / n, 1),
|
|
78
|
+
))
|
|
79
|
+
out.sort(key=lambda s: s.avg_cost_per_turn, reverse=True)
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def summarize_overall(metrics: List[SessionMetric]) -> OverallSummary:
|
|
84
|
+
"""Total spend and bucket split across all sessions."""
|
|
85
|
+
if not metrics:
|
|
86
|
+
return OverallSummary(0, 0.0, {}, {}, 0.0, 0.0)
|
|
87
|
+
|
|
88
|
+
bucket_cost: Dict[str, float] = {}
|
|
89
|
+
total_cost = 0.0
|
|
90
|
+
cpt_sum = 0.0
|
|
91
|
+
cache_sum = 0.0
|
|
92
|
+
for m in metrics:
|
|
93
|
+
total_cost += m.total_cost
|
|
94
|
+
cpt_sum += m.cost_per_turn
|
|
95
|
+
cache_sum += m.cache_hit_rate
|
|
96
|
+
for bucket, cost in m.bucket_cost.items():
|
|
97
|
+
bucket_cost[bucket] = bucket_cost.get(bucket, 0.0) + cost
|
|
98
|
+
|
|
99
|
+
bucket_pct = {
|
|
100
|
+
b: round(100.0 * c / total_cost, 2) if total_cost else 0.0
|
|
101
|
+
for b, c in bucket_cost.items()
|
|
102
|
+
}
|
|
103
|
+
n = len(metrics)
|
|
104
|
+
return OverallSummary(
|
|
105
|
+
num_sessions=n,
|
|
106
|
+
total_cost=round(total_cost, 6),
|
|
107
|
+
bucket_cost={b: round(c, 6) for b, c in bucket_cost.items()},
|
|
108
|
+
bucket_pct=bucket_pct,
|
|
109
|
+
avg_cost_per_turn=round(cpt_sum / n, 8),
|
|
110
|
+
avg_cache_hit_rate=round(cache_sum / n, 4),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def group_by_prompt_version(metrics: List[SessionMetric]) -> List[PromptVersion]:
|
|
115
|
+
"""Group sessions by system-prompt hash; return versions sorted by time."""
|
|
116
|
+
versions: Dict[str, PromptVersion] = {}
|
|
117
|
+
for m in metrics:
|
|
118
|
+
v = versions.get(m.system_prompt_hash)
|
|
119
|
+
if v is None:
|
|
120
|
+
v = PromptVersion(
|
|
121
|
+
system_prompt_hash=m.system_prompt_hash,
|
|
122
|
+
system_prompt_text=m.system_prompt_text,
|
|
123
|
+
num_sessions=0,
|
|
124
|
+
total_cost=0.0,
|
|
125
|
+
avg_cost_per_turn=0.0,
|
|
126
|
+
avg_cache_hit_rate=0.0,
|
|
127
|
+
first_seen=m.last_seen or m.computed_at or "",
|
|
128
|
+
last_seen=m.last_seen or m.computed_at or "",
|
|
129
|
+
)
|
|
130
|
+
versions[m.system_prompt_hash] = v
|
|
131
|
+
v.num_sessions += 1
|
|
132
|
+
v.total_cost += m.total_cost
|
|
133
|
+
v._cpt_samples.append(m.cost_per_turn)
|
|
134
|
+
v.avg_cache_hit_rate += m.cache_hit_rate
|
|
135
|
+
stamp = m.last_seen or m.computed_at or ""
|
|
136
|
+
if stamp:
|
|
137
|
+
v.first_seen = min(v.first_seen or stamp, stamp)
|
|
138
|
+
v.last_seen = max(v.last_seen or stamp, stamp)
|
|
139
|
+
|
|
140
|
+
out: List[PromptVersion] = []
|
|
141
|
+
for v in versions.values():
|
|
142
|
+
n = v.num_sessions or 1
|
|
143
|
+
v.total_cost = round(v.total_cost, 6)
|
|
144
|
+
v.avg_cost_per_turn = round(sum(v._cpt_samples) / n, 8)
|
|
145
|
+
v.avg_cache_hit_rate = round(v.avg_cache_hit_rate / n, 4)
|
|
146
|
+
out.append(v)
|
|
147
|
+
|
|
148
|
+
out.sort(key=lambda v: v.first_seen)
|
|
149
|
+
return out
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Detection — deterministic signals about *what changed*, across any dimension.
|
|
2
|
+
|
|
3
|
+
We do NOT assume a single cause. Cost-per-turn can rise from a model switch,
|
|
4
|
+
history bloat, longer outputs, a broken cache, bigger retrieved context, more
|
|
5
|
+
turns, or a system-prompt change. So we compare a recent window against a prior
|
|
6
|
+
window and surface every dimension that moved, each as grounded evidence.
|
|
7
|
+
|
|
8
|
+
The LLM judge then reasons over these signals to name the likely cause(s). The
|
|
9
|
+
system-prompt change is just one of the signals here, not the whole story.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from ..capture_schema import _parse_ts
|
|
18
|
+
from .aggregate import OverallSummary
|
|
19
|
+
from .metrics import SessionMetric
|
|
20
|
+
|
|
21
|
+
# Thresholds (deliberately simple; tune later).
|
|
22
|
+
_COST_JUMP_PCT = 20.0 # cost-per-turn rise worth flagging
|
|
23
|
+
_CACHE_DROP_ABS = 0.20 # absolute cache-hit-rate drop
|
|
24
|
+
_BUCKET_SHIFT_PCT = 10.0 # percentage-point shift in a bucket's share
|
|
25
|
+
_OUTPUT_GROWTH_PCT = 25.0 # rise in output tokens per turn
|
|
26
|
+
_DOMINANT_BUCKET_PCT = 50.0 # a bucket over this share is worth calling out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Finding:
|
|
31
|
+
"""One flagged signal, with evidence for the judge to reason over."""
|
|
32
|
+
|
|
33
|
+
kind: str # e.g. "cost_increase", "model_shift", "bucket_shift",
|
|
34
|
+
# "cache_drop", "output_growth", "prompt_change"
|
|
35
|
+
severity: str # "high" | "medium" | "low"
|
|
36
|
+
summary: str # human-readable, number-grounded
|
|
37
|
+
evidence: dict = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class PeriodStats:
|
|
42
|
+
"""Aggregate stats for one time window."""
|
|
43
|
+
|
|
44
|
+
num_sessions: int
|
|
45
|
+
avg_cost_per_turn: float
|
|
46
|
+
avg_cache_hit_rate: float
|
|
47
|
+
avg_output_tokens_per_turn: float
|
|
48
|
+
bucket_pct: Dict[str, float]
|
|
49
|
+
model_mix: Dict[str, float] # model -> fraction of sessions
|
|
50
|
+
dominant_prompt_hash: str
|
|
51
|
+
dominant_prompt_text: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _period_stats(metrics: List[SessionMetric]) -> PeriodStats:
|
|
55
|
+
n = len(metrics)
|
|
56
|
+
if n == 0:
|
|
57
|
+
return PeriodStats(0, 0.0, 0.0, 0.0, {}, {}, "", "")
|
|
58
|
+
|
|
59
|
+
cpt = sum(m.cost_per_turn for m in metrics) / n
|
|
60
|
+
cache = sum(m.cache_hit_rate for m in metrics) / n
|
|
61
|
+
out_per_turn = sum(
|
|
62
|
+
(m.output_tokens / m.num_turns) if m.num_turns else 0 for m in metrics
|
|
63
|
+
) / n
|
|
64
|
+
|
|
65
|
+
bucket_cost: Dict[str, float] = {}
|
|
66
|
+
total = 0.0
|
|
67
|
+
for m in metrics:
|
|
68
|
+
total += m.total_cost
|
|
69
|
+
for b, c in m.bucket_cost.items():
|
|
70
|
+
bucket_cost[b] = bucket_cost.get(b, 0.0) + c
|
|
71
|
+
bucket_pct = {
|
|
72
|
+
b: round(100.0 * c / total, 2) if total else 0.0 for b, c in bucket_cost.items()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
model_counts: Dict[str, int] = {}
|
|
76
|
+
hash_counts: Dict[str, int] = {}
|
|
77
|
+
hash_text: Dict[str, str] = {}
|
|
78
|
+
for m in metrics:
|
|
79
|
+
model_counts[m.model] = model_counts.get(m.model, 0) + 1
|
|
80
|
+
hash_counts[m.system_prompt_hash] = hash_counts.get(m.system_prompt_hash, 0) + 1
|
|
81
|
+
hash_text[m.system_prompt_hash] = m.system_prompt_text
|
|
82
|
+
model_mix = {mdl: round(c / n, 3) for mdl, c in model_counts.items()}
|
|
83
|
+
dom_hash = max(hash_counts, key=hash_counts.get) if hash_counts else ""
|
|
84
|
+
|
|
85
|
+
return PeriodStats(
|
|
86
|
+
num_sessions=n,
|
|
87
|
+
avg_cost_per_turn=round(cpt, 8),
|
|
88
|
+
avg_cache_hit_rate=round(cache, 4),
|
|
89
|
+
avg_output_tokens_per_turn=round(out_per_turn, 1),
|
|
90
|
+
bucket_pct=bucket_pct,
|
|
91
|
+
model_mix=model_mix,
|
|
92
|
+
dominant_prompt_hash=dom_hash,
|
|
93
|
+
dominant_prompt_text=hash_text.get(dom_hash, ""),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def split_periods(
|
|
98
|
+
metrics: List[SessionMetric],
|
|
99
|
+
) -> Optional[tuple]:
|
|
100
|
+
"""Split metrics into (prior, recent) halves by time. None if too few."""
|
|
101
|
+
dated = [m for m in metrics if _parse_ts(m.last_seen or m.computed_at)]
|
|
102
|
+
if len(dated) < 4:
|
|
103
|
+
return None
|
|
104
|
+
dated.sort(key=lambda m: _parse_ts(m.last_seen or m.computed_at))
|
|
105
|
+
mid = len(dated) // 2
|
|
106
|
+
return dated[:mid], dated[mid:]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def detect_changes(metrics: List[SessionMetric]) -> List[Finding]:
|
|
110
|
+
"""Compare recent vs prior window; flag every dimension that moved."""
|
|
111
|
+
split = split_periods(metrics)
|
|
112
|
+
if split is None:
|
|
113
|
+
return []
|
|
114
|
+
prior_m, recent_m = split
|
|
115
|
+
prior = _period_stats(prior_m)
|
|
116
|
+
recent = _period_stats(recent_m)
|
|
117
|
+
findings: List[Finding] = []
|
|
118
|
+
|
|
119
|
+
# 1) headline: did cost-per-turn rise?
|
|
120
|
+
cost_jump_pct = None
|
|
121
|
+
if prior.avg_cost_per_turn > 0:
|
|
122
|
+
cost_jump_pct = 100.0 * (
|
|
123
|
+
recent.avg_cost_per_turn - prior.avg_cost_per_turn
|
|
124
|
+
) / prior.avg_cost_per_turn
|
|
125
|
+
if cost_jump_pct >= _COST_JUMP_PCT:
|
|
126
|
+
findings.append(Finding(
|
|
127
|
+
kind="cost_increase",
|
|
128
|
+
severity="high" if cost_jump_pct >= 40 else "medium",
|
|
129
|
+
summary=(
|
|
130
|
+
f"Cost per turn rose {cost_jump_pct:.0f}% "
|
|
131
|
+
f"(${prior.avg_cost_per_turn:.6f} -> ${recent.avg_cost_per_turn:.6f})"
|
|
132
|
+
),
|
|
133
|
+
evidence={
|
|
134
|
+
"prior_cost_per_turn": prior.avg_cost_per_turn,
|
|
135
|
+
"recent_cost_per_turn": recent.avg_cost_per_turn,
|
|
136
|
+
"pct": round(cost_jump_pct, 1),
|
|
137
|
+
},
|
|
138
|
+
))
|
|
139
|
+
|
|
140
|
+
# 2) candidate drivers (surface regardless; judge decides which explains #1)
|
|
141
|
+
# model mix shift / new model
|
|
142
|
+
new_models = {m: f for m, f in recent.model_mix.items()
|
|
143
|
+
if f - prior.model_mix.get(m, 0.0) >= 0.2}
|
|
144
|
+
if new_models:
|
|
145
|
+
findings.append(Finding(
|
|
146
|
+
kind="model_shift",
|
|
147
|
+
severity="medium",
|
|
148
|
+
summary=f"Model mix shifted toward {', '.join(new_models)}",
|
|
149
|
+
evidence={"prior_mix": prior.model_mix, "recent_mix": recent.model_mix},
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
# bucket share shifts (history bloat, output growth, retrieval growth, ...)
|
|
153
|
+
for bucket in set(prior.bucket_pct) | set(recent.bucket_pct):
|
|
154
|
+
delta = recent.bucket_pct.get(bucket, 0.0) - prior.bucket_pct.get(bucket, 0.0)
|
|
155
|
+
if delta >= _BUCKET_SHIFT_PCT:
|
|
156
|
+
findings.append(Finding(
|
|
157
|
+
kind="bucket_shift",
|
|
158
|
+
severity="medium",
|
|
159
|
+
summary=(
|
|
160
|
+
f"'{bucket}' share grew "
|
|
161
|
+
f"{prior.bucket_pct.get(bucket, 0):.0f}% -> "
|
|
162
|
+
f"{recent.bucket_pct.get(bucket, 0):.0f}% of spend"
|
|
163
|
+
),
|
|
164
|
+
evidence={"bucket": bucket,
|
|
165
|
+
"prior_pct": prior.bucket_pct.get(bucket, 0.0),
|
|
166
|
+
"recent_pct": recent.bucket_pct.get(bucket, 0.0)},
|
|
167
|
+
))
|
|
168
|
+
|
|
169
|
+
# cache degradation
|
|
170
|
+
cache_drop = prior.avg_cache_hit_rate - recent.avg_cache_hit_rate
|
|
171
|
+
if cache_drop >= _CACHE_DROP_ABS:
|
|
172
|
+
findings.append(Finding(
|
|
173
|
+
kind="cache_drop",
|
|
174
|
+
severity="high" if cache_drop >= 0.4 else "medium",
|
|
175
|
+
summary=(
|
|
176
|
+
f"Cache-hit rate fell {prior.avg_cache_hit_rate:.0%} -> "
|
|
177
|
+
f"{recent.avg_cache_hit_rate:.0%}"
|
|
178
|
+
),
|
|
179
|
+
evidence={"prior": prior.avg_cache_hit_rate, "recent": recent.avg_cache_hit_rate},
|
|
180
|
+
))
|
|
181
|
+
|
|
182
|
+
# output length growth
|
|
183
|
+
if prior.avg_output_tokens_per_turn > 0:
|
|
184
|
+
out_pct = 100.0 * (
|
|
185
|
+
recent.avg_output_tokens_per_turn - prior.avg_output_tokens_per_turn
|
|
186
|
+
) / prior.avg_output_tokens_per_turn
|
|
187
|
+
if out_pct >= _OUTPUT_GROWTH_PCT:
|
|
188
|
+
findings.append(Finding(
|
|
189
|
+
kind="output_growth",
|
|
190
|
+
severity="medium",
|
|
191
|
+
summary=(
|
|
192
|
+
f"Output length per turn grew {out_pct:.0f}% "
|
|
193
|
+
f"({prior.avg_output_tokens_per_turn:.0f} -> "
|
|
194
|
+
f"{recent.avg_output_tokens_per_turn:.0f} tokens)"
|
|
195
|
+
),
|
|
196
|
+
evidence={"prior": prior.avg_output_tokens_per_turn,
|
|
197
|
+
"recent": recent.avg_output_tokens_per_turn,
|
|
198
|
+
"pct": round(out_pct, 1)},
|
|
199
|
+
))
|
|
200
|
+
|
|
201
|
+
# system-prompt change (one signal among the above)
|
|
202
|
+
if (prior.dominant_prompt_hash and recent.dominant_prompt_hash
|
|
203
|
+
and prior.dominant_prompt_hash != recent.dominant_prompt_hash):
|
|
204
|
+
findings.append(Finding(
|
|
205
|
+
kind="prompt_change",
|
|
206
|
+
severity="medium",
|
|
207
|
+
summary=(
|
|
208
|
+
f"Dominant system prompt changed "
|
|
209
|
+
f"({prior.dominant_prompt_hash} -> {recent.dominant_prompt_hash})"
|
|
210
|
+
),
|
|
211
|
+
evidence={
|
|
212
|
+
"prior_hash": prior.dominant_prompt_hash,
|
|
213
|
+
"recent_hash": recent.dominant_prompt_hash,
|
|
214
|
+
"prior_prompt": prior.dominant_prompt_text,
|
|
215
|
+
"recent_prompt": recent.dominant_prompt_text,
|
|
216
|
+
},
|
|
217
|
+
))
|
|
218
|
+
|
|
219
|
+
return findings
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def detect_dominant_bucket(overall: OverallSummary) -> List[Finding]:
|
|
223
|
+
"""Static view: flag when one bucket dominates the whole bill."""
|
|
224
|
+
findings: List[Finding] = []
|
|
225
|
+
for bucket, pct in overall.bucket_pct.items():
|
|
226
|
+
if pct >= _DOMINANT_BUCKET_PCT:
|
|
227
|
+
findings.append(Finding(
|
|
228
|
+
kind="dominant_bucket",
|
|
229
|
+
severity="medium",
|
|
230
|
+
summary=f"'{bucket}' is {pct:.0f}% of total spend "
|
|
231
|
+
f"(${overall.bucket_cost.get(bucket, 0):.6f})",
|
|
232
|
+
evidence={"bucket": bucket, "pct": pct,
|
|
233
|
+
"cost": overall.bucket_cost.get(bucket, 0.0)},
|
|
234
|
+
))
|
|
235
|
+
return findings
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def detect_all(
|
|
239
|
+
overall: OverallSummary, metrics: List[SessionMetric]
|
|
240
|
+
) -> List[Finding]:
|
|
241
|
+
"""Run every detector; return findings ordered high -> low severity."""
|
|
242
|
+
order = {"high": 0, "medium": 1, "low": 2}
|
|
243
|
+
findings = detect_changes(metrics) + detect_dominant_bucket(overall)
|
|
244
|
+
findings.sort(key=lambda f: order.get(f.severity, 3))
|
|
245
|
+
return findings
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""LLM-as-judge — explain, in plain English, what drove the cost.
|
|
2
|
+
|
|
3
|
+
We never send raw conversations. We send a compact, number-grounded digest:
|
|
4
|
+
the overall spend split plus the deterministic findings (what changed across
|
|
5
|
+
every dimension). The LLM's job is to reason over those signals and name the
|
|
6
|
+
likely cause(s) and a fix — NOT to compute numbers.
|
|
7
|
+
|
|
8
|
+
The HTTP call is injectable so this is unit-testable without network/keys.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Callable, List, Optional
|
|
19
|
+
|
|
20
|
+
from .aggregate import ModelSummary, OverallSummary
|
|
21
|
+
from .detect import Finding
|
|
22
|
+
|
|
23
|
+
_API_URL = "https://api.openai.com/v1/chat/completions"
|
|
24
|
+
_DEFAULT_MODEL = "gpt-4o-mini"
|
|
25
|
+
_PROMPT_TRUNC = 1200 # chars of each system prompt to include
|
|
26
|
+
|
|
27
|
+
_RUBRIC = (
|
|
28
|
+
"You are a FinOps cost auditor for LLM applications. You are given a "
|
|
29
|
+
"GROUNDED digest of real usage: an overall spend breakdown and a list of "
|
|
30
|
+
"detected changes across dimensions (cost/turn, model mix, cost buckets, "
|
|
31
|
+
"cache-hit rate, output length, system-prompt fingerprint).\n\n"
|
|
32
|
+
"Your job:\n"
|
|
33
|
+
"1. State where the money is going (one line).\n"
|
|
34
|
+
"2. If a per-model comparison is present, explain the cost difference "
|
|
35
|
+
"between the models for the same workload (cite cost/turn and output "
|
|
36
|
+
"length).\n"
|
|
37
|
+
"3. If cost rose over time, name the MOST LIKELY cause by correlating the "
|
|
38
|
+
"signals — do NOT assume it was the system prompt; it may be a model "
|
|
39
|
+
"switch, history growth, longer outputs, a broken cache, or something "
|
|
40
|
+
"else. Cite the specific numbers.\n"
|
|
41
|
+
"4. Give one concrete, actionable recommendation.\n\n"
|
|
42
|
+
"Rules: use ONLY the numbers provided; never invent figures. Be concise "
|
|
43
|
+
"(a few sentences). If nothing significant changed, say so."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Judgment:
|
|
49
|
+
verdict: str # the model's plain-English explanation
|
|
50
|
+
model: str
|
|
51
|
+
prompt_chars: int # size of the digest we sent (for transparency)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _default_chat(payload: dict, api_key: str) -> dict:
|
|
55
|
+
req = urllib.request.Request(
|
|
56
|
+
_API_URL,
|
|
57
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
58
|
+
headers={
|
|
59
|
+
"Authorization": f"Bearer {api_key}",
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
},
|
|
62
|
+
method="POST",
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
66
|
+
return json.load(r)
|
|
67
|
+
except urllib.error.HTTPError as e:
|
|
68
|
+
detail = e.read().decode("utf-8", "ignore")[:200]
|
|
69
|
+
raise RuntimeError(f"judge LLM call failed: HTTP {e.code} {detail}") from e
|
|
70
|
+
except urllib.error.URLError as e:
|
|
71
|
+
raise RuntimeError(f"judge LLM call failed: {e.reason}") from e
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_digest(
|
|
75
|
+
overall: OverallSummary,
|
|
76
|
+
findings: List[Finding],
|
|
77
|
+
by_model: Optional[List[ModelSummary]] = None,
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Assemble the compact, grounded text sent to the judge."""
|
|
80
|
+
lines: List[str] = []
|
|
81
|
+
lines.append("=== OVERALL SPEND ===")
|
|
82
|
+
lines.append(f"sessions: {overall.num_sessions}")
|
|
83
|
+
lines.append(f"total_cost: ${overall.total_cost:.6f}")
|
|
84
|
+
lines.append(f"avg_cost_per_turn: ${overall.avg_cost_per_turn:.6f}")
|
|
85
|
+
lines.append(f"avg_cache_hit_rate: {overall.avg_cache_hit_rate:.0%}")
|
|
86
|
+
lines.append("bucket_share_%: " + ", ".join(
|
|
87
|
+
f"{b}={p:.0f}%" for b, p in sorted(overall.bucket_pct.items())
|
|
88
|
+
))
|
|
89
|
+
|
|
90
|
+
if by_model and len(by_model) > 1:
|
|
91
|
+
lines.append("\n=== PER-MODEL COMPARISON (same workload) ===")
|
|
92
|
+
for s in by_model:
|
|
93
|
+
lines.append(
|
|
94
|
+
f"{s.model}: cost/turn=${s.avg_cost_per_turn:.6f}, "
|
|
95
|
+
f"cache_hit={s.avg_cache_hit_rate:.0%}, "
|
|
96
|
+
f"out_tokens/turn={s.avg_output_tokens_per_turn:.0f}, "
|
|
97
|
+
f"sessions={s.num_sessions}, turns={s.total_turns}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
lines.append("\n=== DETECTED CHANGES (recent window vs prior) ===")
|
|
101
|
+
if not findings:
|
|
102
|
+
lines.append("none significant")
|
|
103
|
+
for i, f in enumerate(findings, 1):
|
|
104
|
+
lines.append(f"[{i}] ({f.severity}) {f.kind}: {f.summary}")
|
|
105
|
+
if f.kind == "prompt_change":
|
|
106
|
+
lines.append(" prior_prompt: "
|
|
107
|
+
+ (f.evidence.get("prior_prompt", "")[:_PROMPT_TRUNC]))
|
|
108
|
+
lines.append(" recent_prompt: "
|
|
109
|
+
+ (f.evidence.get("recent_prompt", "")[:_PROMPT_TRUNC]))
|
|
110
|
+
return "\n".join(lines)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def judge(
|
|
114
|
+
overall: OverallSummary,
|
|
115
|
+
findings: List[Finding],
|
|
116
|
+
by_model: Optional[List[ModelSummary]] = None,
|
|
117
|
+
api_key: Optional[str] = None,
|
|
118
|
+
model: str = _DEFAULT_MODEL,
|
|
119
|
+
chat: Optional[Callable[[dict, str], dict]] = None,
|
|
120
|
+
) -> Judgment:
|
|
121
|
+
"""Ask the LLM to explain the cost picture from the grounded digest."""
|
|
122
|
+
key = api_key or os.environ.get("OPENAI_API_KEY", "")
|
|
123
|
+
if not key:
|
|
124
|
+
raise RuntimeError("no API key: set OPENAI_API_KEY or pass api_key=")
|
|
125
|
+
caller = chat or _default_chat
|
|
126
|
+
|
|
127
|
+
digest = build_digest(overall, findings, by_model)
|
|
128
|
+
payload = {
|
|
129
|
+
"model": model,
|
|
130
|
+
"messages": [
|
|
131
|
+
{"role": "system", "content": _RUBRIC},
|
|
132
|
+
{"role": "user", "content": digest},
|
|
133
|
+
],
|
|
134
|
+
"temperature": 0,
|
|
135
|
+
}
|
|
136
|
+
resp = caller(payload, key)
|
|
137
|
+
verdict = (
|
|
138
|
+
resp.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
|
|
139
|
+
or "(no verdict returned)"
|
|
140
|
+
)
|
|
141
|
+
return Judgment(verdict=verdict, model=model, prompt_chars=len(digest))
|