tokenbill 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.
- tokenbill/__init__.py +12 -0
- tokenbill/__main__.py +6 -0
- tokenbill/analyzer.py +187 -0
- tokenbill/breakers.py +369 -0
- tokenbill/cli.py +255 -0
- tokenbill/common.py +46 -0
- tokenbill/demo_traces.py +558 -0
- tokenbill/instrument.py +310 -0
- tokenbill/pricing.py +139 -0
- tokenbill/py.typed +0 -0
- tokenbill/report.py +788 -0
- tokenbill/simulator.py +313 -0
- tokenbill/trace.py +455 -0
- tokenbill-0.1.0.dist-info/METADATA +286 -0
- tokenbill-0.1.0.dist-info/RECORD +18 -0
- tokenbill-0.1.0.dist-info/WHEEL +4 -0
- tokenbill-0.1.0.dist-info/entry_points.txt +2 -0
- tokenbill-0.1.0.dist-info/licenses/LICENSE +21 -0
tokenbill/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Token Bill: token economics and prompt-cache profiling for LLM agents.
|
|
2
|
+
|
|
3
|
+
Answers "why is our agent bill so high" with receipts: parses agent traces,
|
|
4
|
+
computes per-call token waterfalls from real billed usage, measures what share
|
|
5
|
+
of billed input tokens re-sent bytes the model had already seen, simulates what
|
|
6
|
+
prompt caching would actually save under the provider's documented rules, and
|
|
7
|
+
pinpoints the exact orchestration choices (a timestamp in the system prompt, a
|
|
8
|
+
reordered tool list) that break cache hits — each with a concrete fix and the
|
|
9
|
+
dollars it recovers.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
tokenbill/__main__.py
ADDED
tokenbill/analyzer.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Waterfalls, redundancy, and segment attribution over recorded runs.
|
|
2
|
+
|
|
3
|
+
Honesty rules (see docs/SPEC.md):
|
|
4
|
+
|
|
5
|
+
- Dollar figures come from real billed ``usage`` via :mod:`tokenbill.pricing`.
|
|
6
|
+
Exact.
|
|
7
|
+
- Char-based numbers (segment attribution, repeated-prefix fractions, the
|
|
8
|
+
redundancy fraction) are **approximate**, used only to apportion a call's
|
|
9
|
+
billed totals, and every approximate token figure is scaled so segments sum
|
|
10
|
+
to the call's billed ``total_input``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from tokenbill.pricing import cost_breakdown
|
|
19
|
+
from tokenbill.trace import Call, Run, common_prefix_chars, render_segments, rendered_text
|
|
20
|
+
|
|
21
|
+
_DOLLAR_KEYS = ("uncached", "write", "read", "output")
|
|
22
|
+
_TOKEN_KEYS = ("uncached", "cache_write", "cache_read", "output", "total_input")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class SegmentShare:
|
|
27
|
+
"""One rendered segment's approximate share of a call's billed input.
|
|
28
|
+
|
|
29
|
+
``approx_tokens`` is the call's billed ``total_input`` scaled by this
|
|
30
|
+
segment's char fraction — approximate by construction, but the shares of
|
|
31
|
+
one call always sum to the billed total.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
kind: str
|
|
35
|
+
label: str
|
|
36
|
+
chars: int
|
|
37
|
+
char_fraction: float
|
|
38
|
+
approx_tokens: float
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class CallProfile:
|
|
43
|
+
"""Per-call profile: exact billed dollars plus approximate attribution."""
|
|
44
|
+
|
|
45
|
+
call: Call
|
|
46
|
+
dollars: dict[str, float] | None # pricing.cost_breakdown, None if model unknown
|
|
47
|
+
segments: list[SegmentShare] # per-segment approx attribution
|
|
48
|
+
repeated_prefix_chars: int # LCP with previous call (0 for first)
|
|
49
|
+
repeated_fraction_of_input: float # approx: repeated chars / rendered chars
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class RunTotals:
|
|
54
|
+
"""Run-level aggregates.
|
|
55
|
+
|
|
56
|
+
``tokens`` (exact, from billed usage): ``uncached``, ``cache_write``,
|
|
57
|
+
``cache_read``, ``output``, ``total_input``.
|
|
58
|
+
|
|
59
|
+
``dollars`` (exact, billed): ``uncached``, ``write``, ``read``,
|
|
60
|
+
``output``, ``total`` — or ``None`` when any call's model has no pricing
|
|
61
|
+
entry (a partial dollar total would be misleading; tokens stay exact).
|
|
62
|
+
|
|
63
|
+
``redundancy_fraction`` is the headline number: the share of cumulative
|
|
64
|
+
billed input tokens attributable to re-sent byte-identical prefix that
|
|
65
|
+
was NOT served from cache. ``redundancy_is_approx`` is always ``True``:
|
|
66
|
+
the fraction rests on char-based prefix attribution.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
tokens: dict[str, int]
|
|
70
|
+
dollars: dict[str, float] | None
|
|
71
|
+
redundancy_fraction: float
|
|
72
|
+
redundancy_is_approx: bool = True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class RunProfile:
|
|
77
|
+
"""Everything the report needs about one run."""
|
|
78
|
+
|
|
79
|
+
run: Run
|
|
80
|
+
calls: list[CallProfile]
|
|
81
|
+
totals: RunTotals
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _segment_parts(segment: Any) -> tuple[str, str, str]:
|
|
85
|
+
"""Normalize a trace Segment to ``(kind, label, text)``.
|
|
86
|
+
|
|
87
|
+
The SPEC writes Segment as a 3-tuple; tolerate an attribute-bearing
|
|
88
|
+
equivalent so a NamedTuple or dataclass from trace.py also works.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
kind, label, text = segment
|
|
92
|
+
except (TypeError, ValueError):
|
|
93
|
+
kind, label, text = segment.kind, segment.label, segment.text
|
|
94
|
+
return str(kind), str(label), str(text)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _profile_call(call: Call, prev: Call | None) -> tuple[CallProfile, float]:
|
|
98
|
+
"""Build one CallProfile; also return this call's wasted-input tokens."""
|
|
99
|
+
rendered_chars = len(rendered_text(call))
|
|
100
|
+
total_input = call.usage.total_input
|
|
101
|
+
|
|
102
|
+
# Millions of shares are built for a large run; keep the loop body lean
|
|
103
|
+
# (positional construction, hoisted append, one division).
|
|
104
|
+
segments: list[SegmentShare] = []
|
|
105
|
+
append = segments.append
|
|
106
|
+
inv_chars = (1.0 / rendered_chars) if rendered_chars else 0.0
|
|
107
|
+
for segment in render_segments(call):
|
|
108
|
+
kind, label, text = _segment_parts(segment)
|
|
109
|
+
chars = len(text)
|
|
110
|
+
fraction = chars * inv_chars
|
|
111
|
+
append(SegmentShare(kind, label, chars, fraction, total_input * fraction))
|
|
112
|
+
|
|
113
|
+
if prev is None:
|
|
114
|
+
repeated_chars = 0
|
|
115
|
+
else:
|
|
116
|
+
repeated_chars = common_prefix_chars(prev, call)
|
|
117
|
+
repeated_fraction = (repeated_chars / rendered_chars) if rendered_chars else 0.0
|
|
118
|
+
|
|
119
|
+
# Redundancy contribution (see profile_run docstring): repeated prefix
|
|
120
|
+
# char fraction x billed total_input, minus tokens already served from
|
|
121
|
+
# cache; clamped at 0 per call.
|
|
122
|
+
wasted = max(0.0, total_input * repeated_fraction - call.usage.cache_read_input_tokens)
|
|
123
|
+
|
|
124
|
+
profile = CallProfile(
|
|
125
|
+
call=call,
|
|
126
|
+
dollars=cost_breakdown(call.model, call.usage),
|
|
127
|
+
segments=segments,
|
|
128
|
+
repeated_prefix_chars=repeated_chars,
|
|
129
|
+
repeated_fraction_of_input=repeated_fraction,
|
|
130
|
+
)
|
|
131
|
+
return profile, wasted
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def profile_run(run: Run) -> RunProfile:
|
|
135
|
+
"""Profile one run: per-call waterfalls plus the run redundancy fraction.
|
|
136
|
+
|
|
137
|
+
Redundancy definition (the headline number; also in DESIGN.md): for call
|
|
138
|
+
``i > 0`` the re-sent prefix is ``common_prefix_chars(call[i-1],
|
|
139
|
+
call[i])`` of the canonical rendered text. Its token value is the call's
|
|
140
|
+
billed ``total_input`` scaled by the repeated char fraction; the portion
|
|
141
|
+
already served as ``cache_read_input_tokens`` is subtracted (cache reads
|
|
142
|
+
are cheap — they are not waste), and each call's contribution is clamped
|
|
143
|
+
at 0. The fraction is the sum of those contributions divided by the sum
|
|
144
|
+
of billed ``total_input`` over all calls. Approximate by construction
|
|
145
|
+
(char-based attribution); labeled via ``redundancy_is_approx``.
|
|
146
|
+
"""
|
|
147
|
+
call_profiles: list[CallProfile] = []
|
|
148
|
+
wasted_total = 0.0
|
|
149
|
+
prev: Call | None = None
|
|
150
|
+
for call in run.calls:
|
|
151
|
+
profile, wasted = _profile_call(call, prev)
|
|
152
|
+
call_profiles.append(profile)
|
|
153
|
+
wasted_total += wasted
|
|
154
|
+
prev = call
|
|
155
|
+
|
|
156
|
+
tokens = dict.fromkeys(_TOKEN_KEYS, 0)
|
|
157
|
+
for call in run.calls:
|
|
158
|
+
tokens["uncached"] += call.usage.input_tokens
|
|
159
|
+
tokens["cache_write"] += call.usage.cache_creation_input_tokens
|
|
160
|
+
tokens["cache_read"] += call.usage.cache_read_input_tokens
|
|
161
|
+
tokens["output"] += call.usage.output_tokens
|
|
162
|
+
tokens["total_input"] += call.usage.total_input
|
|
163
|
+
|
|
164
|
+
dollars: dict[str, float] | None
|
|
165
|
+
if any(p.dollars is None for p in call_profiles):
|
|
166
|
+
dollars = None
|
|
167
|
+
else:
|
|
168
|
+
dollars = dict.fromkeys(_DOLLAR_KEYS, 0.0)
|
|
169
|
+
for profile in call_profiles:
|
|
170
|
+
assert profile.dollars is not None # narrowed by the branch above
|
|
171
|
+
for key in _DOLLAR_KEYS:
|
|
172
|
+
dollars[key] += profile.dollars[key]
|
|
173
|
+
dollars["total"] = sum(dollars[key] for key in _DOLLAR_KEYS)
|
|
174
|
+
|
|
175
|
+
billed_input = tokens["total_input"]
|
|
176
|
+
redundancy = (wasted_total / billed_input) if billed_input else 0.0
|
|
177
|
+
|
|
178
|
+
return RunProfile(
|
|
179
|
+
run=run,
|
|
180
|
+
calls=call_profiles,
|
|
181
|
+
totals=RunTotals(tokens=tokens, dollars=dollars, redundancy_fraction=redundancy),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def profile_trace(runs: list[Run]) -> list[RunProfile]:
|
|
186
|
+
"""Profile every run in a trace, preserving order."""
|
|
187
|
+
return [profile_run(run) for run in runs]
|
tokenbill/breakers.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Cache-breaker detection: divergence classification, fixes, and repair.
|
|
2
|
+
|
|
3
|
+
For each pair of consecutive calls the first matching rule wins (SPEC priority
|
|
4
|
+
order):
|
|
5
|
+
|
|
6
|
+
1. ``model`` differs from the previous call → ``model-switch``.
|
|
7
|
+
2. diverging segment is tools → ``tool-churn``.
|
|
8
|
+
3. diverging segment is system → ``volatile-system`` when the changed span
|
|
9
|
+
strictly overlaps a :data:`VOLATILE_PATTERNS` match (ISO dates/times, unix
|
|
10
|
+
timestamps, UUIDs, monotonic counters) AND substituting every volatile
|
|
11
|
+
match makes the two system prompts byte-identical — i.e. the volatile
|
|
12
|
+
values fully explain the divergence. Otherwise ``system-edit`` — the
|
|
13
|
+
non-volatile variant with its own fix sentence (whose repair, pinning the
|
|
14
|
+
system text, also covers mixed volatile-plus-edit changes).
|
|
15
|
+
4. diverging segment is a message that already existed in the previous call →
|
|
16
|
+
``history-rewrite``.
|
|
17
|
+
5. no divergence, shared prefix at least the model's minimum cacheable length
|
|
18
|
+
(approx tokens), but ``cache_breakpoints == 0`` and billed cache activity
|
|
19
|
+
is zero → ``missing-breakpoint``.
|
|
20
|
+
|
|
21
|
+
:func:`detect` reports one :class:`Breaker` per distinct cause with the first
|
|
22
|
+
call index where it bites (the demo's ``timestamp`` scenario diverges on every
|
|
23
|
+
call but yields exactly one ``volatile-system`` breaker at call 1).
|
|
24
|
+
``est_recovered_usd`` isolates each cause: the run is re-simulated with only
|
|
25
|
+
that one breaker repaired, and the value is as-billed minus fixed-cache
|
|
26
|
+
dollars, floored at 0 (positive = money recovered by the fix; approx, since
|
|
27
|
+
the fixed-cache scenario is simulated; 0 when billed caching already beats
|
|
28
|
+
the simulated single-breakpoint policy — the report words that case
|
|
29
|
+
explicitly rather than showing a negative "recovery"). Causes with no
|
|
30
|
+
mechanical repair (``model-switch``, ``history-rewrite``) get ``None`` — a
|
|
31
|
+
billed-minus-fixed number there would price the optimal replay of the
|
|
32
|
+
still-broken run, which is not attributable to the displayed fix.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import re
|
|
38
|
+
from dataclasses import dataclass, replace
|
|
39
|
+
|
|
40
|
+
from tokenbill.common import canonical_json
|
|
41
|
+
from tokenbill.pricing import PRICING, ModelPricing
|
|
42
|
+
|
|
43
|
+
# _as_billed/_replay are simulator internals shared within the package: the
|
|
44
|
+
# per-breaker estimate needs exactly one as-billed total per run and one
|
|
45
|
+
# fixed-cache replay per breaker — calling the full simulate() here would
|
|
46
|
+
# redundantly re-replay optimal-cache for every detected breaker.
|
|
47
|
+
from tokenbill.simulator import _as_billed, _replay
|
|
48
|
+
from tokenbill.trace import Call, Run, approx_tokens, diverging_segment, rendered_text
|
|
49
|
+
|
|
50
|
+
# Heuristic patterns for content that legitimately changes every call and
|
|
51
|
+
# therefore must not live in the cached prefix. Tested as a module constant
|
|
52
|
+
# (SPEC). Order matters for :func:`repaired_calls`: the combined ISO datetime
|
|
53
|
+
# comes first so one substitution covers the whole stamp.
|
|
54
|
+
VOLATILE_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
55
|
+
# ISO datetime, e.g. "2026-07-26T14:03:05Z" or "2026-07-26 14:03:05".
|
|
56
|
+
re.compile(r"\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b"),
|
|
57
|
+
# ISO date, e.g. "2026-07-26".
|
|
58
|
+
re.compile(r"\b\d{4}-\d{2}-\d{2}\b"),
|
|
59
|
+
# Clock time, e.g. "14:03" or "14:03:05".
|
|
60
|
+
re.compile(r"\b\d{1,2}:\d{2}(?::\d{2})?\b"),
|
|
61
|
+
# Unix timestamp in seconds (~2017..2051 range guard against arbitrary
|
|
62
|
+
# 10-digit numbers).
|
|
63
|
+
re.compile(r"\b(?:1[5-9]|2[0-5])\d{8}\b"),
|
|
64
|
+
# UUID.
|
|
65
|
+
re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"),
|
|
66
|
+
# Monotonic counters, e.g. "attempt 3", "seq=42", "turn #7".
|
|
67
|
+
re.compile(
|
|
68
|
+
r"\b(?:seq|counter|attempt|turn|step|iteration|request|call|run)[ _#:=-]{1,3}\d+\b",
|
|
69
|
+
re.IGNORECASE,
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
#: What repaired volatile spans are replaced with (stable across all calls).
|
|
74
|
+
STABLE_PLACEHOLDER = "<volatile>"
|
|
75
|
+
|
|
76
|
+
_DEFAULT_LIMITS = ModelPricing(input_per_mtok=0.0, output_per_mtok=0.0)
|
|
77
|
+
|
|
78
|
+
_FIXES: dict[str, str] = {
|
|
79
|
+
"model-switch": (
|
|
80
|
+
"prompt caches are per-model: keep one model for the whole run, or expect a "
|
|
81
|
+
"cold cache after every switch"
|
|
82
|
+
),
|
|
83
|
+
"tool-churn": (
|
|
84
|
+
"send tool definitions in one fixed order on every call (sort them once at "
|
|
85
|
+
"startup); reordering rewrites the cached prefix"
|
|
86
|
+
),
|
|
87
|
+
"volatile-system": (
|
|
88
|
+
"move the volatile value (timestamp/UUID/counter) out of the system prompt — "
|
|
89
|
+
"inject it in the latest user message instead"
|
|
90
|
+
),
|
|
91
|
+
"system-edit": (
|
|
92
|
+
"keep the system prompt byte-stable for the whole run; put per-turn context in "
|
|
93
|
+
"the latest user message instead of editing the system text"
|
|
94
|
+
),
|
|
95
|
+
"history-rewrite": (
|
|
96
|
+
"append new messages instead of rewriting earlier ones; any edit above the "
|
|
97
|
+
"cache breakpoint invalidates the cached prefix"
|
|
98
|
+
),
|
|
99
|
+
"missing-breakpoint": (
|
|
100
|
+
"add a cache_control breakpoint (for example on the last message); the stable "
|
|
101
|
+
"prefix already meets the minimum cacheable length"
|
|
102
|
+
),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# SPEC rule order, used only to order breakers that first bite at the same call.
|
|
106
|
+
_KIND_PRIORITY = {
|
|
107
|
+
"model-switch": 0,
|
|
108
|
+
"tool-churn": 1,
|
|
109
|
+
"volatile-system": 2,
|
|
110
|
+
"system-edit": 2,
|
|
111
|
+
"history-rewrite": 3,
|
|
112
|
+
"missing-breakpoint": 4,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class Breaker:
|
|
118
|
+
"""One detected cache-breaking cause with a concrete, priced fix.
|
|
119
|
+
|
|
120
|
+
``kind`` is one of ``model-switch``, ``tool-churn``, ``volatile-system``,
|
|
121
|
+
``system-edit`` (the non-volatile system-divergence variant),
|
|
122
|
+
``history-rewrite``, ``missing-breakpoint``. ``evidence`` shows the exact
|
|
123
|
+
changed span, truncated, with char offsets. ``est_recovered_usd`` is
|
|
124
|
+
as-billed minus fixed-cache dollars with only this breaker repaired,
|
|
125
|
+
floored at 0 (positive = recovered; 0 = billed caching already beats the
|
|
126
|
+
simulated repair; ``None`` when pricing is unknown or when the kind has
|
|
127
|
+
no mechanical repair — ``model-switch`` and ``history-rewrite`` — so no
|
|
128
|
+
honest per-fix dollar figure exists).
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
kind: str
|
|
132
|
+
first_call_index: int
|
|
133
|
+
evidence: str
|
|
134
|
+
fix: str
|
|
135
|
+
est_recovered_usd: float | None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _truncate(text: str, limit: int = 80) -> str:
|
|
139
|
+
if len(text) <= limit:
|
|
140
|
+
return text
|
|
141
|
+
keep = (limit - 3) // 2
|
|
142
|
+
return f"{text[:keep]}...{text[-keep:]}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _changed_window(prev: str, cur: str) -> tuple[int, int, int]:
|
|
146
|
+
"""``(lo, hi_prev, hi_cur)``: minimal spans ``prev[lo:hi_prev]`` vs ``cur[lo:hi_cur]``."""
|
|
147
|
+
lo = 0
|
|
148
|
+
limit = min(len(prev), len(cur))
|
|
149
|
+
while lo < limit and prev[lo] == cur[lo]:
|
|
150
|
+
lo += 1
|
|
151
|
+
hi_prev, hi_cur = len(prev), len(cur)
|
|
152
|
+
while hi_prev > lo and hi_cur > lo and prev[hi_prev - 1] == cur[hi_cur - 1]:
|
|
153
|
+
hi_prev -= 1
|
|
154
|
+
hi_cur -= 1
|
|
155
|
+
return lo, hi_prev, hi_cur
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _volatile_overlap(text: str, lo: int, hi: int) -> re.Match[str] | None:
|
|
159
|
+
"""First volatile-pattern match strictly overlapping ``text[lo:hi]``.
|
|
160
|
+
|
|
161
|
+
Strict means at least one shared character: a match that merely touches
|
|
162
|
+
the changed span (e.g. a stable date immediately before an edited
|
|
163
|
+
punctuation mark) must not classify the change as volatile.
|
|
164
|
+
"""
|
|
165
|
+
for pattern in VOLATILE_PATTERNS:
|
|
166
|
+
for match in pattern.finditer(text):
|
|
167
|
+
if match.start() < hi and match.end() > lo:
|
|
168
|
+
return match
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _excerpt(text: str, lo: int, hi: int, context: int = 24) -> str:
|
|
173
|
+
start = max(0, lo - context)
|
|
174
|
+
end = min(len(text), hi + context)
|
|
175
|
+
prefix = "..." if start > 0 else ""
|
|
176
|
+
suffix = "..." if end < len(text) else ""
|
|
177
|
+
return prefix + _truncate(text[start:end]) + suffix
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _tool_names(call: Call) -> list[str]:
|
|
181
|
+
return [str(tool.get("name", "?")) for tool in call.tools]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _classify_system(prev: Call, cur: Call) -> tuple[str, str]:
|
|
185
|
+
lo, hi_prev, hi_cur = _changed_window(prev.system, cur.system)
|
|
186
|
+
match_cur = _volatile_overlap(cur.system, lo, hi_cur)
|
|
187
|
+
match_prev = _volatile_overlap(prev.system, lo, hi_prev)
|
|
188
|
+
# Volatile only when the volatile values fully explain the divergence:
|
|
189
|
+
# after substituting every volatile match the systems must be identical.
|
|
190
|
+
# Otherwise the volatile repair would leave the run still diverging and
|
|
191
|
+
# the honest classification (and fix) is system-edit.
|
|
192
|
+
volatile = (match_cur is not None or match_prev is not None) and _stabilized(
|
|
193
|
+
prev.system
|
|
194
|
+
) == _stabilized(cur.system)
|
|
195
|
+
if volatile:
|
|
196
|
+
# Widen each excerpt to cover the volatile token so the evidence shows
|
|
197
|
+
# the full stamp, not just the digits that changed.
|
|
198
|
+
prev_lo = min(lo, match_prev.start()) if match_prev else lo
|
|
199
|
+
prev_hi = max(hi_prev, match_prev.end()) if match_prev else hi_prev
|
|
200
|
+
cur_lo = min(lo, match_cur.start()) if match_cur else lo
|
|
201
|
+
cur_hi = max(hi_cur, match_cur.end()) if match_cur else hi_cur
|
|
202
|
+
evidence = (
|
|
203
|
+
f"system chars [{cur_lo}:{cur_hi}] at call {cur.index}: "
|
|
204
|
+
f"{_excerpt(prev.system, prev_lo, prev_hi)!r} -> "
|
|
205
|
+
f"{_excerpt(cur.system, cur_lo, cur_hi)!r}"
|
|
206
|
+
)
|
|
207
|
+
return "volatile-system", evidence
|
|
208
|
+
evidence = (
|
|
209
|
+
f"system chars [{lo}:{hi_cur}] at call {cur.index}: "
|
|
210
|
+
f"{_excerpt(prev.system, lo, hi_prev)!r} -> {_excerpt(cur.system, lo, hi_cur)!r}"
|
|
211
|
+
)
|
|
212
|
+
return "system-edit", evidence
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _classify_pair(prev: Call, cur: Call, first_seen_tools: list[str]) -> tuple[str, str] | None:
|
|
216
|
+
"""Classify one consecutive pair per the SPEC priority order."""
|
|
217
|
+
if cur.model != prev.model:
|
|
218
|
+
return "model-switch", (
|
|
219
|
+
f"model changed at call {cur.index}: {prev.model!r} -> {cur.model!r}"
|
|
220
|
+
)
|
|
221
|
+
divergence = diverging_segment(prev, cur)
|
|
222
|
+
if divergence is not None:
|
|
223
|
+
seg_index, seg_kind = divergence
|
|
224
|
+
if seg_kind == "tools":
|
|
225
|
+
return "tool-churn", (
|
|
226
|
+
f"tool order changed at call {cur.index}: first seen "
|
|
227
|
+
f"{first_seen_tools} -> {_tool_names(cur)}"
|
|
228
|
+
)
|
|
229
|
+
if seg_kind == "system":
|
|
230
|
+
return _classify_system(prev, cur)
|
|
231
|
+
# Message segments start after the tools and system segments.
|
|
232
|
+
msg_index = seg_index - 2
|
|
233
|
+
if 0 <= msg_index < len(prev.messages):
|
|
234
|
+
prev_text = canonical_json(prev.messages[msg_index])
|
|
235
|
+
cur_text = (
|
|
236
|
+
canonical_json(cur.messages[msg_index]) if msg_index < len(cur.messages) else ""
|
|
237
|
+
)
|
|
238
|
+
lo, hi_prev, hi_cur = _changed_window(prev_text, cur_text)
|
|
239
|
+
return "history-rewrite", (
|
|
240
|
+
f"messages[{msg_index}] rewritten at call {cur.index}, chars "
|
|
241
|
+
f"[{lo}:{hi_cur}]: {_excerpt(prev_text, lo, hi_prev)!r} -> "
|
|
242
|
+
f"{_excerpt(cur_text, lo, hi_cur)!r}"
|
|
243
|
+
)
|
|
244
|
+
return None
|
|
245
|
+
# Rule 5: byte-stable prefix that was never cached.
|
|
246
|
+
usage = cur.usage
|
|
247
|
+
if (
|
|
248
|
+
cur.cache_breakpoints == 0
|
|
249
|
+
and usage.cache_read_input_tokens == 0
|
|
250
|
+
and usage.cache_creation_input_tokens == 0
|
|
251
|
+
):
|
|
252
|
+
limits = PRICING.get(cur.model) or _DEFAULT_LIMITS
|
|
253
|
+
prev_text = rendered_text(prev)
|
|
254
|
+
shared_chars = min(len(prev_text), len(rendered_text(cur)))
|
|
255
|
+
prefix_tokens = approx_tokens(prev_text[:shared_chars])
|
|
256
|
+
if prefix_tokens >= limits.min_cacheable_prefix_tokens:
|
|
257
|
+
return "missing-breakpoint", (
|
|
258
|
+
f"calls {prev.index}->{cur.index} share a byte-stable prefix of "
|
|
259
|
+
f"~{prefix_tokens:.0f} approx tokens (min cacheable "
|
|
260
|
+
f"{limits.min_cacheable_prefix_tokens}) but cache_breakpoints=0 "
|
|
261
|
+
"and billed cache activity is 0"
|
|
262
|
+
)
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def detect(run: Run) -> list[Breaker]:
|
|
267
|
+
"""Detect cache breakers in *run*: one :class:`Breaker` per distinct cause.
|
|
268
|
+
|
|
269
|
+
Consecutive call pairs are classified per the SPEC priority order; events
|
|
270
|
+
of the same kind are collapsed to the first call index where the cause
|
|
271
|
+
bites. Each breaker's ``est_recovered_usd`` is computed by re-simulating
|
|
272
|
+
the run with only that breaker repaired (see :func:`repaired_calls`), so
|
|
273
|
+
the dollar estimate isolates each cause; kinds with no mechanical repair
|
|
274
|
+
report ``None`` instead of a number the fix cannot claim.
|
|
275
|
+
"""
|
|
276
|
+
calls = run.calls
|
|
277
|
+
if len(calls) < 2:
|
|
278
|
+
return []
|
|
279
|
+
first_seen_tools = _tool_names(calls[0])
|
|
280
|
+
events: dict[str, tuple[int, str]] = {}
|
|
281
|
+
for i in range(1, len(calls)):
|
|
282
|
+
classified = _classify_pair(calls[i - 1], calls[i], first_seen_tools)
|
|
283
|
+
if classified is None:
|
|
284
|
+
continue
|
|
285
|
+
kind, evidence = classified
|
|
286
|
+
events.setdefault(kind, (i, evidence))
|
|
287
|
+
|
|
288
|
+
ordered = sorted(events.items(), key=lambda item: (item[1][0], _KIND_PRIORITY[item[0]]))
|
|
289
|
+
# As-billed dollars are a property of the run, not of any repair: price
|
|
290
|
+
# them once instead of once per breaker (replays dominate detect() time).
|
|
291
|
+
billed = _as_billed(calls).dollars if ordered else None
|
|
292
|
+
breakers: list[Breaker] = []
|
|
293
|
+
for kind, (index, evidence) in ordered:
|
|
294
|
+
breaker = Breaker(
|
|
295
|
+
kind=kind,
|
|
296
|
+
first_call_index=index,
|
|
297
|
+
evidence=evidence,
|
|
298
|
+
fix=_FIXES[kind],
|
|
299
|
+
est_recovered_usd=None,
|
|
300
|
+
)
|
|
301
|
+
breakers.append(replace(breaker, est_recovered_usd=_estimate(run, breaker, billed)))
|
|
302
|
+
return breakers
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _estimate(run: Run, breaker: Breaker, billed: float | None) -> float | None:
|
|
306
|
+
"""As-billed minus fixed-cache dollars with only *breaker* repaired.
|
|
307
|
+
|
|
308
|
+
Floored at 0: billed usage can legitimately beat the simulated
|
|
309
|
+
single-breakpoint replay (e.g. real multi-breakpoint caching), and a
|
|
310
|
+
negative "recovery" would claim the fix costs money — the report words
|
|
311
|
+
that case explicitly instead. ``None`` when the repair left the calls
|
|
312
|
+
untouched (no mechanical repair exists — model-switch, history-rewrite):
|
|
313
|
+
billed minus fixed would then be the optimal replay of the still-broken
|
|
314
|
+
run, a number the displayed fix cannot claim. ``None`` also when any
|
|
315
|
+
call's model is unpriced (*billed* arrives as ``None``).
|
|
316
|
+
"""
|
|
317
|
+
repaired = repaired_calls(run, [breaker])
|
|
318
|
+
if repaired == list(run.calls):
|
|
319
|
+
return None
|
|
320
|
+
if billed is None:
|
|
321
|
+
return None
|
|
322
|
+
fixed = _replay(repaired, "fixed-cache", "").dollars
|
|
323
|
+
if fixed is None:
|
|
324
|
+
return None
|
|
325
|
+
return max(0.0, billed - fixed)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _stabilized(system: str) -> str:
|
|
329
|
+
for pattern in VOLATILE_PATTERNS:
|
|
330
|
+
system = pattern.sub(STABLE_PLACEHOLDER, system)
|
|
331
|
+
return system
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def repaired_calls(run: Run, breakers: list[Breaker]) -> list[Call]:
|
|
335
|
+
"""Return *run*'s calls with the given breakers neutralized for simulation.
|
|
336
|
+
|
|
337
|
+
Repairs (SPEC): volatile spans in the system prompt are replaced by the
|
|
338
|
+
stable :data:`STABLE_PLACEHOLDER`; a mid-run system edit is pinned to the
|
|
339
|
+
first call's system text; tool order is restored to first-seen order;
|
|
340
|
+
``cache_breakpoints=1`` where it was 0. ``model-switch`` and
|
|
341
|
+
``history-rewrite`` have no mechanical repair (rewriting content or
|
|
342
|
+
models would change semantics) and leave the calls untouched. Content
|
|
343
|
+
semantics are never mutated otherwise; billed ``usage`` is preserved so
|
|
344
|
+
the fixed-cache simulation still scales to real billed totals.
|
|
345
|
+
"""
|
|
346
|
+
kinds = {breaker.kind for breaker in breakers}
|
|
347
|
+
calls = list(run.calls)
|
|
348
|
+
if "tool-churn" in kinds:
|
|
349
|
+
first_seen: dict[str, int] = {}
|
|
350
|
+
for call in calls:
|
|
351
|
+
for tool in call.tools:
|
|
352
|
+
first_seen.setdefault(canonical_json(tool), len(first_seen))
|
|
353
|
+
calls = [
|
|
354
|
+
replace(
|
|
355
|
+
call,
|
|
356
|
+
tools=tuple(sorted(call.tools, key=lambda t: first_seen[canonical_json(t)])),
|
|
357
|
+
)
|
|
358
|
+
for call in calls
|
|
359
|
+
]
|
|
360
|
+
if "volatile-system" in kinds:
|
|
361
|
+
calls = [replace(call, system=_stabilized(call.system)) for call in calls]
|
|
362
|
+
if "system-edit" in kinds and calls:
|
|
363
|
+
calls = [replace(call, system=calls[0].system) for call in calls]
|
|
364
|
+
if "missing-breakpoint" in kinds:
|
|
365
|
+
calls = [
|
|
366
|
+
replace(call, cache_breakpoints=1) if call.cache_breakpoints == 0 else call
|
|
367
|
+
for call in calls
|
|
368
|
+
]
|
|
369
|
+
return calls
|