tokenaudit 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.
Files changed (42) hide show
  1. tokenaudit/__init__.py +3 -0
  2. tokenaudit/__main__.py +152 -0
  3. tokenaudit/ci/__init__.py +1 -0
  4. tokenaudit/counterfactual/__init__.py +1 -0
  5. tokenaudit/detect/__init__.py +15 -0
  6. tokenaudit/detect/base.py +96 -0
  7. tokenaudit/detect/corpus_record.json +56 -0
  8. tokenaudit/detect/engine.py +87 -0
  9. tokenaudit/detect/findings.py +86 -0
  10. tokenaudit/detect/rules/__init__.py +1 -0
  11. tokenaudit/detect/rules/breakpoint_limit.py +102 -0
  12. tokenaudit/detect/rules/context_bloat.py +105 -0
  13. tokenaudit/detect/rules/lookback_overflow.py +120 -0
  14. tokenaudit/detect/rules/model_switch.py +107 -0
  15. tokenaudit/detect/rules/prefix_drift.py +157 -0
  16. tokenaudit/detect/rules/retry_storm.py +102 -0
  17. tokenaudit/detect/rules/sub_minimum.py +112 -0
  18. tokenaudit/detect/rules/tool_schema_churn.py +120 -0
  19. tokenaudit/detect/rules/ttl_expiry.py +121 -0
  20. tokenaudit/examples/sample.trace.json +512 -0
  21. tokenaudit/ingest/__init__.py +39 -0
  22. tokenaudit/ingest/containers.py +172 -0
  23. tokenaudit/ingest/dedup.py +64 -0
  24. tokenaudit/ingest/dialects.py +258 -0
  25. tokenaudit/ingest/quality.py +65 -0
  26. tokenaudit/ingest/sessionize.py +47 -0
  27. tokenaudit/model/__init__.py +5 -0
  28. tokenaudit/model/records.py +99 -0
  29. tokenaudit/py.typed +0 -0
  30. tokenaudit/report/__init__.py +14 -0
  31. tokenaudit/report/corpus_record.py +35 -0
  32. tokenaudit/report/health.py +86 -0
  33. tokenaudit/report/html.py +152 -0
  34. tokenaudit/rulebook/__init__.py +238 -0
  35. tokenaudit/rulebook/anthropic.json +36 -0
  36. tokenaudit/rulebook/google.json +23 -0
  37. tokenaudit/rulebook/openai.json +45 -0
  38. tokenaudit-0.1.0.dist-info/METADATA +111 -0
  39. tokenaudit-0.1.0.dist-info/RECORD +42 -0
  40. tokenaudit-0.1.0.dist-info/WHEEL +4 -0
  41. tokenaudit-0.1.0.dist-info/entry_points.txt +2 -0
  42. tokenaudit-0.1.0.dist-info/licenses/LICENSE +202 -0
tokenaudit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """tokenaudit: root-cause diagnosis of wasted LLM tokens from GenAI traces."""
2
+
3
+ __version__ = "0.1.0"
tokenaudit/__main__.py ADDED
@@ -0,0 +1,152 @@
1
+ """Command-line entry point: ``python -m tokenaudit ingest <paths>``.
2
+
3
+ Prints the normalized ingest result as JSON (default) or a human summary
4
+ (``--summary``). A dedicated console script with a fuller CLI arrives with
5
+ the report phase; this stays argparse-only so the package keeps zero
6
+ runtime dependencies.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ from collections import Counter
15
+ from dataclasses import asdict
16
+ from importlib.resources import files
17
+ from pathlib import Path
18
+
19
+ from tokenaudit import __version__
20
+ from tokenaudit.detect import SessionDiagnosis, diagnose
21
+ from tokenaudit.ingest import UnknownTraceFormatError, ingest_path
22
+ from tokenaudit.model.records import IngestResult
23
+ from tokenaudit.report import render_html
24
+ from tokenaudit.rulebook import load_rulebook
25
+
26
+
27
+ def _trace_files(path: Path) -> list[Path]:
28
+ if path.is_file():
29
+ return [path]
30
+ if path.is_dir():
31
+ found = sorted(p for p in path.rglob("*") if p.name.endswith((".trace.json", ".jsonl")))
32
+ if not found:
33
+ raise UnknownTraceFormatError(f"{path}: no trace files found")
34
+ return found
35
+ raise UnknownTraceFormatError(f"{path}: no such file or directory")
36
+
37
+
38
+ def _summarize(result: IngestResult) -> str:
39
+ lines = [
40
+ f"{result.source} [{result.container}] "
41
+ f"spans={result.spans_total} skipped={result.spans_skipped} "
42
+ f"deduped={len(result.deduped_span_ids)}"
43
+ ]
44
+ for session in result.sessions:
45
+ requests = session.requests
46
+ known_input = [r.usage.input_total for r in requests if r.usage.input_total is not None]
47
+ known_read = [r.usage.cache_read for r in requests if r.usage.cache_read is not None]
48
+ total_input = sum(known_input)
49
+ total_read = sum(known_read)
50
+ hit = f"{total_read / total_input * 100:.1f}%" if total_input and known_read else "n/a"
51
+ flags = Counter(flag for r in requests for flag in r.quality)
52
+ unpriceable = sum(1 for r in requests if r.unpriceable)
53
+ lines.append(
54
+ f" session {session.session_id} ({session.grouping}): "
55
+ f"{len(requests)} requests, input={total_input}, "
56
+ f"cache_read={total_read} ({hit}), unpriceable={unpriceable}"
57
+ )
58
+ if flags:
59
+ rendered = ", ".join(f"{name} x{count}" for name, count in sorted(flags.items()))
60
+ lines.append(f" flags: {rendered}")
61
+ return "\n".join(lines)
62
+
63
+
64
+ def _render_diagnosis(diagnosis: SessionDiagnosis) -> str:
65
+ lines = [
66
+ f"session {diagnosis.session_id} "
67
+ f"({diagnosis.requests_examined} requests examined, "
68
+ f"{diagnosis.requests_skipped_unpriceable} unpriceable skipped)"
69
+ ]
70
+ if not diagnosis.findings:
71
+ lines.append(" no findings")
72
+ for finding in diagnosis.findings:
73
+ lines.append(
74
+ f" [{finding.severity.value.upper()}] {finding.detector_id}: {finding.summary}"
75
+ )
76
+ if finding.impact is not None:
77
+ impact = finding.impact
78
+ tokens = (
79
+ f"~{impact.tokens_low:,} tokens"
80
+ if impact.tokens_low == impact.tokens_high
81
+ else f"~{impact.tokens_low:,}-{impact.tokens_high:,} tokens"
82
+ )
83
+ lines.append(f" waste: {tokens} full-price equivalent ({impact.assumption})")
84
+ lines.append(f" fix: {finding.fix.summary}")
85
+ clean = [d for d in diagnosis.checked if d not in {f.detector_id for f in diagnosis.findings}]
86
+ if clean:
87
+ lines.append(f" checked, clean: {', '.join(clean)}")
88
+ for detector_id, reason in diagnosis.abstained.items():
89
+ lines.append(f" not checked: {detector_id} ({reason})")
90
+ return "\n".join(lines)
91
+
92
+
93
+ def main(argv: list[str] | None = None) -> int:
94
+ parser = argparse.ArgumentParser(prog="tokenaudit")
95
+ parser.add_argument("--version", action="version", version=f"tokenaudit {__version__}")
96
+ subparsers = parser.add_subparsers(dest="command", required=True)
97
+ ingest = subparsers.add_parser("ingest", help="normalize trace files")
98
+ ingest.add_argument("paths", nargs="+", type=Path)
99
+ ingest.add_argument("--summary", action="store_true", help="human summary instead of JSON")
100
+ diag = subparsers.add_parser("diagnose", help="diagnose token waste in trace files")
101
+ diag.add_argument("paths", nargs="*", type=Path)
102
+ diag.add_argument("--json", action="store_true", help="machine-readable output")
103
+ diag.add_argument("--html", type=Path, help="also write a self-contained HTML report")
104
+ diag.add_argument(
105
+ "--sample", action="store_true", help="diagnose the bundled sample trace (a demo)"
106
+ )
107
+ subparsers.add_parser("rulebook", help="render the provider caching rulebook as markdown")
108
+ args = parser.parse_args(argv)
109
+
110
+ if args.command == "rulebook":
111
+ print(load_rulebook().render_markdown())
112
+ return 0
113
+
114
+ paths = list(args.paths)
115
+ if args.command == "diagnose" and args.sample:
116
+ paths.append(Path(str(files("tokenaudit.examples").joinpath("sample.trace.json"))))
117
+ if not paths:
118
+ parser.error("no trace paths given (or use: tokenaudit diagnose --sample)")
119
+ results = []
120
+ try:
121
+ for path in paths:
122
+ for trace_file in _trace_files(path):
123
+ results.append(ingest_path(trace_file))
124
+ except UnknownTraceFormatError as exc:
125
+ print(f"error: {exc}", file=sys.stderr)
126
+ return 2
127
+
128
+ if args.command == "diagnose":
129
+ per_result = [(result, diagnose(result)) for result in results]
130
+ if args.html:
131
+ args.html.write_text(render_html(per_result, load_rulebook()), encoding="utf-8")
132
+ print(f"wrote {args.html}", file=sys.stderr)
133
+ if args.json:
134
+ payload = [asdict(d) for _, diagnoses in per_result for d in diagnoses]
135
+ print(json.dumps(payload, indent=2, default=str))
136
+ else:
137
+ for result, diagnoses in per_result:
138
+ print(result.source)
139
+ for diagnosis in diagnoses:
140
+ print(_render_diagnosis(diagnosis))
141
+ return 0
142
+
143
+ if args.summary:
144
+ for result in results:
145
+ print(_summarize(result))
146
+ else:
147
+ print(json.dumps([asdict(result) for result in results], indent=2))
148
+ return 0
149
+
150
+
151
+ if __name__ == "__main__":
152
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """CI cost-regression gate."""
@@ -0,0 +1 @@
1
+ """Counterfactual pricing: what the bill would have been under a fix."""
@@ -0,0 +1,15 @@
1
+ """The diagnosis engine: deterministic waste detectors."""
2
+
3
+ from tokenaudit.detect.engine import DETECTORS, diagnose, diagnose_session
4
+ from tokenaudit.detect.findings import Finding, Fix, Impact, SessionDiagnosis, Severity
5
+
6
+ __all__ = [
7
+ "DETECTORS",
8
+ "Finding",
9
+ "Fix",
10
+ "Impact",
11
+ "SessionDiagnosis",
12
+ "Severity",
13
+ "diagnose",
14
+ "diagnose_session",
15
+ ]
@@ -0,0 +1,96 @@
1
+ """Detector contract and shared helpers.
2
+
3
+ A detector is a pure function of ``(Session, Rulebook)``. It must do one of
4
+ three things, and the distinction is part of the product:
5
+
6
+ - **abstain** with a reason when it cannot examine the session (no content
7
+ captured, model unknown to the rulebook, too few requests) — reported as
8
+ "not checked", never as "clean";
9
+ - **run and find nothing** — reported as checked-and-clean;
10
+ - **run and emit findings**, each carrying evidence a stranger could verify
11
+ against the raw trace.
12
+
13
+ Token estimates from block lengths use the same ~4-chars-per-token stand-in
14
+ as the corpus simulator, and every impact that relies on it says so in its
15
+ assumption string.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from abc import ABC, abstractmethod
21
+ from dataclasses import dataclass
22
+ from itertools import pairwise
23
+ from typing import ClassVar
24
+
25
+ from tokenaudit.detect.findings import Finding
26
+ from tokenaudit.model.records import BlockDigest, Request, Session
27
+ from tokenaudit.rulebook import Rulebook
28
+
29
+ CHARS_PER_TOKEN = 4
30
+ TOKEN_ESTIMATE_NOTE = "block token counts estimated from lengths at ~4 chars/token"
31
+
32
+
33
+ class Abstain(Exception):
34
+ """Raised by a detector that cannot examine this session; carries the reason."""
35
+
36
+ def __init__(self, reason: str) -> None:
37
+ super().__init__(reason)
38
+ self.reason = reason
39
+
40
+
41
+ class Detector(ABC):
42
+ detector_id: ClassVar[str]
43
+
44
+ @abstractmethod
45
+ def check(self, session: Session, rulebook: Rulebook) -> list[Finding]:
46
+ """Return findings for this session, or raise :class:`Abstain`."""
47
+
48
+
49
+ def est_tokens(chars: int) -> int:
50
+ return max(1, chars // CHARS_PER_TOKEN)
51
+
52
+
53
+ def blocks_tokens(blocks: tuple[BlockDigest, ...]) -> int:
54
+ return sum(est_tokens(b.chars) for b in blocks)
55
+
56
+
57
+ def last_marked_index(blocks: tuple[BlockDigest, ...]) -> int | None:
58
+ marked = [i for i, b in enumerate(blocks) if b.cache_marker]
59
+ return marked[-1] if marked else None
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Pair:
64
+ """Two consecutive same-model requests that both captured content."""
65
+
66
+ first: Request
67
+ second: Request
68
+
69
+ @property
70
+ def first_divergence(self) -> int | None:
71
+ a = self.first.blocks or ()
72
+ b = self.second.blocks or ()
73
+ for i in range(min(len(a), len(b))):
74
+ if a[i].sha256 != b[i].sha256:
75
+ return i
76
+ return None
77
+
78
+
79
+ def consecutive_pairs(session: Session) -> list[Pair]:
80
+ """Adjacent same-model request pairs with content on both sides."""
81
+ pairs: list[Pair] = []
82
+ requests = session.requests
83
+ for first, second in pairwise(requests):
84
+ if first.blocks and second.blocks and first.model == second.model:
85
+ pairs.append(Pair(first, second))
86
+ return pairs
87
+
88
+
89
+ def require_content(session: Session, minimum_requests: int = 1) -> list[Request]:
90
+ with_content = [r for r in session.requests if r.blocks]
91
+ if len(with_content) < minimum_requests:
92
+ raise Abstain(
93
+ f"needs at least {minimum_requests} request(s) with captured message content; "
94
+ "usage numbers alone can prove waste but not its cause"
95
+ )
96
+ return with_content
@@ -0,0 +1,56 @@
1
+ {
2
+ "breakpoint_limit": {
3
+ "false_alarms": 0,
4
+ "negatives_checked": 14,
5
+ "positives_found": 1,
6
+ "positives_total": 1
7
+ },
8
+ "context_bloat": {
9
+ "false_alarms": 0,
10
+ "negatives_checked": 12,
11
+ "positives_found": 1,
12
+ "positives_total": 1
13
+ },
14
+ "lookback_overflow": {
15
+ "false_alarms": 0,
16
+ "negatives_checked": 14,
17
+ "positives_found": 1,
18
+ "positives_total": 1
19
+ },
20
+ "model_switch": {
21
+ "false_alarms": 0,
22
+ "negatives_checked": 14,
23
+ "positives_found": 1,
24
+ "positives_total": 1
25
+ },
26
+ "prefix_drift": {
27
+ "false_alarms": 0,
28
+ "negatives_checked": 12,
29
+ "positives_found": 3,
30
+ "positives_total": 4
31
+ },
32
+ "retry_storm": {
33
+ "false_alarms": 0,
34
+ "negatives_checked": 14,
35
+ "positives_found": 1,
36
+ "positives_total": 1
37
+ },
38
+ "sub_minimum": {
39
+ "false_alarms": 0,
40
+ "negatives_checked": 14,
41
+ "positives_found": 1,
42
+ "positives_total": 1
43
+ },
44
+ "tool_schema_churn": {
45
+ "false_alarms": 0,
46
+ "negatives_checked": 14,
47
+ "positives_found": 1,
48
+ "positives_total": 1
49
+ },
50
+ "ttl_expiry": {
51
+ "false_alarms": 0,
52
+ "negatives_checked": 12,
53
+ "positives_found": 1,
54
+ "positives_total": 1
55
+ }
56
+ }
@@ -0,0 +1,87 @@
1
+ """The engine: run every registered detector over ingested sessions.
2
+
3
+ Contract enforced here rather than trusted to each detector:
4
+
5
+ - requests the quality gate marked ``unpriceable`` are removed from the
6
+ session view before any detector sees them, and the count is reported —
7
+ wrong dollar figures are worse than no figures;
8
+ - an abstention (with its reason) and a clean pass are kept apart in the
9
+ result, because "not checked" and "checked, nothing found" support very
10
+ different conclusions;
11
+ - findings come back ordered by severity, then by impact upper bound.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import replace
17
+
18
+ from tokenaudit.detect.base import Abstain, Detector
19
+ from tokenaudit.detect.findings import Finding, SessionDiagnosis, Severity
20
+ from tokenaudit.detect.rules.breakpoint_limit import BreakpointLimitDetector
21
+ from tokenaudit.detect.rules.context_bloat import ContextBloatDetector
22
+ from tokenaudit.detect.rules.lookback_overflow import LookbackOverflowDetector
23
+ from tokenaudit.detect.rules.model_switch import ModelSwitchDetector
24
+ from tokenaudit.detect.rules.prefix_drift import PrefixDriftDetector
25
+ from tokenaudit.detect.rules.retry_storm import RetryStormDetector
26
+ from tokenaudit.detect.rules.sub_minimum import SubMinimumDetector
27
+ from tokenaudit.detect.rules.tool_schema_churn import ToolSchemaChurnDetector
28
+ from tokenaudit.detect.rules.ttl_expiry import TtlExpiryDetector
29
+ from tokenaudit.model.records import IngestResult, Session
30
+ from tokenaudit.rulebook import Rulebook, load_rulebook
31
+
32
+ DETECTORS: tuple[Detector, ...] = (
33
+ PrefixDriftDetector(),
34
+ SubMinimumDetector(),
35
+ ModelSwitchDetector(),
36
+ TtlExpiryDetector(),
37
+ BreakpointLimitDetector(),
38
+ LookbackOverflowDetector(),
39
+ ToolSchemaChurnDetector(),
40
+ ContextBloatDetector(),
41
+ RetryStormDetector(),
42
+ )
43
+
44
+ _SEVERITY_ORDER = {Severity.HIGH: 0, Severity.MEDIUM: 1, Severity.LOW: 2}
45
+
46
+
47
+ def _rank(finding: Finding) -> tuple[int, int]:
48
+ impact = finding.impact.tokens_high if finding.impact else 0
49
+ return (_SEVERITY_ORDER[finding.severity], -impact)
50
+
51
+
52
+ def diagnose_session(
53
+ session: Session,
54
+ rulebook: Rulebook,
55
+ detectors: tuple[Detector, ...] = DETECTORS,
56
+ ) -> SessionDiagnosis:
57
+ priceable = tuple(r for r in session.requests if not r.unpriceable)
58
+ skipped = len(session.requests) - len(priceable)
59
+ view = replace(session, requests=priceable)
60
+
61
+ findings: list[Finding] = []
62
+ checked: list[str] = []
63
+ abstained: dict[str, str] = {}
64
+ for detector in detectors:
65
+ try:
66
+ found = detector.check(view, rulebook)
67
+ except Abstain as abstention:
68
+ abstained[detector.detector_id] = abstention.reason
69
+ else:
70
+ checked.append(detector.detector_id)
71
+ findings.extend(found)
72
+
73
+ source = session.requests[0].source if session.requests else ""
74
+ return SessionDiagnosis(
75
+ session_id=session.session_id,
76
+ source=source,
77
+ findings=tuple(sorted(findings, key=_rank)),
78
+ checked=tuple(checked),
79
+ abstained=abstained,
80
+ requests_examined=len(priceable),
81
+ requests_skipped_unpriceable=skipped,
82
+ )
83
+
84
+
85
+ def diagnose(result: IngestResult, rulebook: Rulebook | None = None) -> list[SessionDiagnosis]:
86
+ book = rulebook or load_rulebook()
87
+ return [diagnose_session(session, book) for session in result.sessions]
@@ -0,0 +1,86 @@
1
+ """The diagnosis result schema — frozen early, because everything downstream
2
+ (report rendering, the eval harness, the CI gate) builds on this shape.
3
+
4
+ Design commitments:
5
+
6
+ - A finding is a **claim with evidence**: every field a stranger would need
7
+ to verify it by hand against the raw trace is present, or the finding
8
+ should not exist.
9
+ - ``Impact`` is a **bounded range with a stated assumption**, denominated in
10
+ full-price-equivalent input tokens; dollars appear only when the rulebook
11
+ holds a verified price for the model. No point estimates, no unstated
12
+ extrapolation, and the rulebook version that produced the numbers rides
13
+ along.
14
+ - ``confidence`` is **measured, never asserted**: it stays ``None`` until
15
+ the evaluation harness computes per-detector precision against the
16
+ labeled corpus and attaches it at report time.
17
+ - A detector that could not examine a session **abstains with a reason** —
18
+ a different fact from "checked and found nothing", and the report must
19
+ keep the two apart.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+ from enum import StrEnum
26
+ from typing import Any
27
+
28
+
29
+ class Severity(StrEnum):
30
+ HIGH = "high"
31
+ MEDIUM = "medium"
32
+ LOW = "low"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Impact:
37
+ """Waste attributed to one finding, as a bounded range.
38
+
39
+ ``tokens_*`` are full-price-equivalent input tokens: the amount of input
40
+ that was (or will keep being) billed at the full rate but would have
41
+ been served at the cached rate under the fix. One finding at a time —
42
+ impacts of different findings on the same session are not additive.
43
+ """
44
+
45
+ tokens_low: int
46
+ tokens_high: int
47
+ usd_low: float | None
48
+ usd_high: float | None
49
+ assumption: str
50
+ rulebook_version: str
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Fix:
55
+ summary: str
56
+ hint: str | None = None
57
+ doc_url: str | None = None
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Finding:
62
+ """One diagnosed cause of waste in one session."""
63
+
64
+ detector_id: str
65
+ severity: Severity
66
+ session_id: str
67
+ source: str
68
+ span_ids: tuple[str, ...]
69
+ summary: str
70
+ evidence: dict[str, Any]
71
+ impact: Impact | None # None when the quality gate blocked pricing
72
+ fix: Fix
73
+ confidence: float | None = None # measured precision, attached by the eval harness
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class SessionDiagnosis:
78
+ """Everything the engine concluded about one session."""
79
+
80
+ session_id: str
81
+ source: str
82
+ findings: tuple[Finding, ...]
83
+ checked: tuple[str, ...] # detectors that ran (clean if not in findings)
84
+ abstained: dict[str, str] = field(default_factory=dict) # detector_id -> reason
85
+ requests_examined: int = 0
86
+ requests_skipped_unpriceable: int = 0
@@ -0,0 +1 @@
1
+ """Detector rules, one module per failure mode."""
@@ -0,0 +1,102 @@
1
+ """Breakpoint limit: more cache markers than the provider honors.
2
+
3
+ Anthropic honors at most 4 explicit cache breakpoints per request; content
4
+ marked beyond the honored set is silently re-billed every call. Modeled as
5
+ silent truncation, matching the corpus simulator — a note rides the
6
+ evidence because live-API behavior for over-marking should be confirmed
7
+ before this claim is made in a published case study.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from tokenaudit.detect.base import (
13
+ TOKEN_ESTIMATE_NOTE,
14
+ Abstain,
15
+ Detector,
16
+ blocks_tokens,
17
+ require_content,
18
+ )
19
+ from tokenaudit.detect.findings import Finding, Fix, Impact, Severity
20
+ from tokenaudit.model.records import Session
21
+ from tokenaudit.rulebook import Rulebook
22
+
23
+
24
+ class BreakpointLimitDetector(Detector):
25
+ detector_id = "breakpoint_limit"
26
+
27
+ def check(self, session: Session, rulebook: Rulebook) -> list[Finding]:
28
+ requests = require_content(session, minimum_requests=1)
29
+ over: list[tuple[int, int, int]] = [] # (request idx, marked count, unhonored tokens)
30
+ max_allowed: int | None = None
31
+ for i, request in enumerate(requests):
32
+ blocks = request.blocks or ()
33
+ marked_indices = [j for j, b in enumerate(blocks) if b.cache_marker]
34
+ if not marked_indices:
35
+ continue
36
+ rules = rulebook.lookup(request.model, request.provider)
37
+ if rules is None:
38
+ raise Abstain(f"model not in the rulebook: {request.model}")
39
+ if rules.max_explicit_breakpoints is None:
40
+ continue # provider has no explicit-marker concept
41
+ max_allowed = rules.max_explicit_breakpoints
42
+ if len(marked_indices) > max_allowed:
43
+ honored_end = marked_indices[max_allowed - 1]
44
+ last_marked = marked_indices[-1]
45
+ unhonored = blocks_tokens(blocks[honored_end + 1 : last_marked + 1])
46
+ over.append((i, len(marked_indices), unhonored))
47
+ if not over or max_allowed is None:
48
+ return []
49
+
50
+ repeat_calls = max(len(over) - 1, 1)
51
+ unhonored_tokens = max(tokens for _, _, tokens in over)
52
+ # Stable-tail check: the low bound needs the unhonored region to be
53
+ # identical across calls, otherwise it wouldn't have cached anyway.
54
+ tails = {tuple(b.sha256 for b in (requests[i].blocks or ()))[-2:] for i, _, _ in over}
55
+ tokens_high = round(0.9 * unhonored_tokens * repeat_calls)
56
+ tokens_low = tokens_high if len(tails) == 1 else 0
57
+
58
+ first_idx, marked_count, _ = over[0]
59
+ rules = rulebook.lookup(requests[first_idx].model, requests[first_idx].provider)
60
+ finding = Finding(
61
+ detector_id=self.detector_id,
62
+ severity=Severity.MEDIUM,
63
+ session_id=session.session_id,
64
+ source=requests[first_idx].source,
65
+ span_ids=tuple(requests[i].span_id for i, _, _ in over),
66
+ summary=(
67
+ f"{marked_count} blocks carry cache markers but the provider honors at most "
68
+ f"{max_allowed}; ~{unhonored_tokens} tokens of marked content past the limit "
69
+ "are re-billed on every call"
70
+ ),
71
+ evidence={
72
+ "marked_blocks": marked_count,
73
+ "max_explicit_breakpoints": max_allowed,
74
+ "unhonored_est_tokens": unhonored_tokens,
75
+ "calls_affected": len(over),
76
+ "note": (
77
+ "over-marking modeled as silent truncation of the extra markers; "
78
+ "confirm live-API behavior before publishing this claim"
79
+ ),
80
+ },
81
+ impact=Impact(
82
+ tokens_low=tokens_low,
83
+ tokens_high=tokens_high,
84
+ usd_low=None,
85
+ usd_high=None,
86
+ assumption=(
87
+ "the region past the last honored marker would have been cached and read "
88
+ f"on repeat calls (0.9x delta); {TOKEN_ESTIMATE_NOTE}; low bound requires "
89
+ "a byte-stable tail"
90
+ ),
91
+ rulebook_version=rulebook.version,
92
+ ),
93
+ fix=Fix(
94
+ summary=(
95
+ "Consolidate cached content behind at most the honored number of markers "
96
+ "— merge adjacent stable blocks so one marker covers them."
97
+ ),
98
+ hint=None,
99
+ doc_url=rules.source_url if rules else None,
100
+ ),
101
+ )
102
+ return [finding]