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 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))