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.
- tokenaudit/__init__.py +3 -0
- tokenaudit/__main__.py +152 -0
- tokenaudit/ci/__init__.py +1 -0
- tokenaudit/counterfactual/__init__.py +1 -0
- tokenaudit/detect/__init__.py +15 -0
- tokenaudit/detect/base.py +96 -0
- tokenaudit/detect/corpus_record.json +56 -0
- tokenaudit/detect/engine.py +87 -0
- tokenaudit/detect/findings.py +86 -0
- tokenaudit/detect/rules/__init__.py +1 -0
- tokenaudit/detect/rules/breakpoint_limit.py +102 -0
- tokenaudit/detect/rules/context_bloat.py +105 -0
- tokenaudit/detect/rules/lookback_overflow.py +120 -0
- tokenaudit/detect/rules/model_switch.py +107 -0
- tokenaudit/detect/rules/prefix_drift.py +157 -0
- tokenaudit/detect/rules/retry_storm.py +102 -0
- tokenaudit/detect/rules/sub_minimum.py +112 -0
- tokenaudit/detect/rules/tool_schema_churn.py +120 -0
- tokenaudit/detect/rules/ttl_expiry.py +121 -0
- tokenaudit/examples/sample.trace.json +512 -0
- tokenaudit/ingest/__init__.py +39 -0
- tokenaudit/ingest/containers.py +172 -0
- tokenaudit/ingest/dedup.py +64 -0
- tokenaudit/ingest/dialects.py +258 -0
- tokenaudit/ingest/quality.py +65 -0
- tokenaudit/ingest/sessionize.py +47 -0
- tokenaudit/model/__init__.py +5 -0
- tokenaudit/model/records.py +99 -0
- tokenaudit/py.typed +0 -0
- tokenaudit/report/__init__.py +14 -0
- tokenaudit/report/corpus_record.py +35 -0
- tokenaudit/report/health.py +86 -0
- tokenaudit/report/html.py +152 -0
- tokenaudit/rulebook/__init__.py +238 -0
- tokenaudit/rulebook/anthropic.json +36 -0
- tokenaudit/rulebook/google.json +23 -0
- tokenaudit/rulebook/openai.json +45 -0
- tokenaudit-0.1.0.dist-info/METADATA +111 -0
- tokenaudit-0.1.0.dist-info/RECORD +42 -0
- tokenaudit-0.1.0.dist-info/WHEEL +4 -0
- tokenaudit-0.1.0.dist-info/entry_points.txt +2 -0
- tokenaudit-0.1.0.dist-info/licenses/LICENSE +202 -0
tokenaudit/__init__.py
ADDED
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]
|