cache-autopsy 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.
- cache_autopsy/__init__.py +21 -0
- cache_autopsy/__main__.py +5 -0
- cache_autopsy/cli.py +143 -0
- cache_autopsy/cost.py +96 -0
- cache_autopsy/demo.py +34 -0
- cache_autopsy/diff.py +138 -0
- cache_autopsy/lint.py +92 -0
- cache_autopsy/model.py +154 -0
- cache_autopsy/pricing.json +36 -0
- cache_autopsy/providers/__init__.py +53 -0
- cache_autopsy/providers/anthropic.py +103 -0
- cache_autopsy/providers/openai.py +102 -0
- cache_autopsy/py.typed +0 -0
- cache_autopsy/records.py +88 -0
- cache_autopsy/render.py +103 -0
- cache_autopsy/report.py +109 -0
- cache_autopsy/rules.py +115 -0
- cache_autopsy/samples/anthropic_broken.jsonl +8 -0
- cache_autopsy/samples/anthropic_fixed.jsonl +8 -0
- cache_autopsy/samples/openai_chat.jsonl +5 -0
- cache_autopsy/samples/openai_responses.jsonl +4 -0
- cache_autopsy/samples/requests/clean.json +51 -0
- cache_autopsy/samples/requests/tools_a.json +46 -0
- cache_autopsy/samples/requests/tools_b.json +46 -0
- cache_autopsy/samples/requests/volatile_system.json +51 -0
- cache_autopsy/verdict.py +160 -0
- cache_autopsy-0.1.0.dist-info/METADATA +383 -0
- cache_autopsy-0.1.0.dist-info/RECORD +31 -0
- cache_autopsy-0.1.0.dist-info/WHEEL +4 -0
- cache_autopsy-0.1.0.dist-info/entry_points.txt +2 -0
- cache_autopsy-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""cache-autopsy: find out why an LLM prompt cache missed, offline.
|
|
2
|
+
|
|
3
|
+
Public API::
|
|
4
|
+
|
|
5
|
+
diff_requests(a, b, provider=None) -> Divergence
|
|
6
|
+
analyze_records(records, pricing=None, session_key=None) -> Report
|
|
7
|
+
lint_request(req, provider=None) -> list[Finding]
|
|
8
|
+
"""
|
|
9
|
+
from .cost import Pricing
|
|
10
|
+
from .diff import diff_requests
|
|
11
|
+
from .lint import lint_request
|
|
12
|
+
from .model import (CacheAutopsyError, Divergence, Finding, InputFormatError, PairVerdict, Report,
|
|
13
|
+
UnknownProviderError, Usage)
|
|
14
|
+
from .verdict import analyze_records
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
__all__ = [
|
|
18
|
+
"diff_requests", "analyze_records", "lint_request", "Pricing",
|
|
19
|
+
"Divergence", "PairVerdict", "Report", "Finding", "Usage",
|
|
20
|
+
"CacheAutopsyError", "InputFormatError", "UnknownProviderError", "__version__",
|
|
21
|
+
]
|
cache_autopsy/cli.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Command line entry point. Exit codes: 0 ok, 1 gate failed, 2 input error."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .cost import Pricing
|
|
9
|
+
from .diff import diff_requests
|
|
10
|
+
from .lint import has_problems, lint_request
|
|
11
|
+
from .model import CacheAutopsyError
|
|
12
|
+
from .records import read_jsonl, read_request
|
|
13
|
+
from .report import analysis_json, analysis_md, analysis_text, divergence_text, findings_text
|
|
14
|
+
from .rules import CAUSES, causes_markdown
|
|
15
|
+
from .verdict import DEFAULT_MIN_TOKENS, analyze_records
|
|
16
|
+
|
|
17
|
+
PROVIDER_CHOICES = ["auto", "anthropic", "openai_chat", "openai_responses"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parser() -> argparse.ArgumentParser:
|
|
21
|
+
p = argparse.ArgumentParser(prog="cache-autopsy",
|
|
22
|
+
description="Find out why an LLM prompt cache missed (offline, no network).")
|
|
23
|
+
p.add_argument("--version", action="version", version=f"cache-autopsy {__version__}")
|
|
24
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
25
|
+
|
|
26
|
+
def common(sp, fmt=True):
|
|
27
|
+
sp.add_argument("--provider", choices=PROVIDER_CHOICES, default="auto", help="force the provider (default: detect)")
|
|
28
|
+
sp.add_argument("--context", type=int, default=40, help="characters of context to show (default 40)")
|
|
29
|
+
sp.add_argument("--redact", action="store_true", help="do not print any request text")
|
|
30
|
+
if fmt:
|
|
31
|
+
sp.add_argument("--format", choices=["text", "json", "md"], default="text")
|
|
32
|
+
|
|
33
|
+
d = sub.add_parser("diff", help="first divergence between two request bodies")
|
|
34
|
+
d.add_argument("a"), d.add_argument("b")
|
|
35
|
+
common(d)
|
|
36
|
+
|
|
37
|
+
a = sub.add_parser("analyze", help="hit rate, missed cost and causes for a JSONL call log")
|
|
38
|
+
a.add_argument("log")
|
|
39
|
+
common(a)
|
|
40
|
+
a.add_argument("--pricing", help="JSON file overriding the bundled prices")
|
|
41
|
+
a.add_argument("--session-key", help="dotted path to group calls, e.g. request.metadata.user_id")
|
|
42
|
+
a.add_argument("--min-tokens", type=int, default=DEFAULT_MIN_TOKENS, help="provider minimum cacheable tokens")
|
|
43
|
+
a.add_argument("--fail-under", type=float, help="exit 1 if the overall hit rate is below this (0-1)")
|
|
44
|
+
a.add_argument("--fail-on", help="comma list of verdicts or cause codes that make the run fail")
|
|
45
|
+
|
|
46
|
+
l = sub.add_parser("lint", help="risky patterns in one request body")
|
|
47
|
+
l.add_argument("request")
|
|
48
|
+
common(l, fmt=False)
|
|
49
|
+
l.add_argument("--min-tokens", type=int, default=DEFAULT_MIN_TOKENS)
|
|
50
|
+
|
|
51
|
+
c = sub.add_parser("causes", help="list cause codes and how to fix them")
|
|
52
|
+
c.add_argument("--format", choices=["text", "md"], default="text")
|
|
53
|
+
|
|
54
|
+
sub.add_parser("demo", help="run the bundled practice (mock) data, no keys needed")
|
|
55
|
+
return p
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _provider(args) -> str | None:
|
|
59
|
+
return None if args.provider == "auto" else args.provider
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
VERDICTS = {"hit", "expected_hit", "explained_miss", "unexplained_miss", "unexplained"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _check_gate_options(args) -> set[str]:
|
|
66
|
+
"""Validate --fail-under / --fail-on before any work so a typo cannot silently disable the gate."""
|
|
67
|
+
if args.fail_under is not None and not 0 <= args.fail_under <= 1:
|
|
68
|
+
raise CacheAutopsyError(f"--fail-under must be a ratio between 0 and 1 (got {args.fail_under}). "
|
|
69
|
+
"Write 0.6 for 60%.")
|
|
70
|
+
wanted = {w.strip() for w in (args.fail_on or "").split(",") if w.strip()}
|
|
71
|
+
unknown = sorted(wanted - set(CAUSES) - VERDICTS)
|
|
72
|
+
if unknown:
|
|
73
|
+
raise CacheAutopsyError(f"unknown --fail-on value(s): {', '.join(unknown)}. "
|
|
74
|
+
"Run `cache-autopsy causes` for cause codes; verdicts are "
|
|
75
|
+
+ ", ".join(sorted(VERDICTS)) + ".")
|
|
76
|
+
return wanted
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _run(args) -> int:
|
|
80
|
+
if args.cmd == "diff":
|
|
81
|
+
div = diff_requests(read_request(args.a), read_request(args.b), _provider(args), args.context)
|
|
82
|
+
if args.format == "json":
|
|
83
|
+
import json
|
|
84
|
+
print(json.dumps(div.to_dict(), indent=2, ensure_ascii=False))
|
|
85
|
+
else:
|
|
86
|
+
print(divergence_text(div, args.redact))
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
if args.cmd == "analyze":
|
|
90
|
+
wanted = _check_gate_options(args)
|
|
91
|
+
pricing = Pricing.from_file(args.pricing) if args.pricing else Pricing.default()
|
|
92
|
+
report = analyze_records(read_jsonl(args.log), pricing, args.session_key, _provider(args),
|
|
93
|
+
args.min_tokens, args.context)
|
|
94
|
+
if report.summary["records"] == 0:
|
|
95
|
+
print(f"warning: no records in {args.log}.", file=sys.stderr)
|
|
96
|
+
render = {"text": analysis_text, "json": analysis_json, "md": analysis_md}[args.format]
|
|
97
|
+
print(render(report, args.redact))
|
|
98
|
+
failed = False
|
|
99
|
+
rate = report.summary["hit_rate"]
|
|
100
|
+
if args.fail_under is not None:
|
|
101
|
+
if rate is None:
|
|
102
|
+
print("cannot apply --fail-under: the log has no usage data.", file=sys.stderr)
|
|
103
|
+
return 2
|
|
104
|
+
if rate < args.fail_under:
|
|
105
|
+
print(f"FAIL: hit rate {rate:.2f} < {args.fail_under:.2f}", file=sys.stderr)
|
|
106
|
+
failed = True
|
|
107
|
+
if args.fail_on:
|
|
108
|
+
hits = [p for p in report.pairs if p.verdict in wanted or wanted & set(p.causes) or p.reason in wanted]
|
|
109
|
+
if hits:
|
|
110
|
+
print(f"FAIL: {len(hits)} comparison(s) match --fail-on {args.fail_on}", file=sys.stderr)
|
|
111
|
+
failed = True
|
|
112
|
+
return 1 if failed else 0
|
|
113
|
+
|
|
114
|
+
if args.cmd == "lint":
|
|
115
|
+
findings = lint_request(read_request(args.request), _provider(args), args.min_tokens)
|
|
116
|
+
print(findings_text(findings))
|
|
117
|
+
return 1 if has_problems(findings) else 0
|
|
118
|
+
|
|
119
|
+
if args.cmd == "causes":
|
|
120
|
+
if args.format == "md":
|
|
121
|
+
print(causes_markdown(), end="")
|
|
122
|
+
else:
|
|
123
|
+
for code, (layer, meaning, fix, _) in CAUSES.items():
|
|
124
|
+
print(f"{code} [{layer}]\n {meaning}\n fix: {fix}")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
from .demo import run_demo
|
|
128
|
+
return run_demo()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main(argv: list[str] | None = None) -> int:
|
|
132
|
+
args = _parser().parse_args(argv)
|
|
133
|
+
try:
|
|
134
|
+
return _run(args)
|
|
135
|
+
except CacheAutopsyError as exc:
|
|
136
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
137
|
+
return 2
|
|
138
|
+
except BrokenPipeError: # e.g. `| head`
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
sys.exit(main())
|
cache_autopsy/cost.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Pricing lookup and the missed-cost arithmetic."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .model import InputFormatError, Usage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Rates:
|
|
15
|
+
"""USD per 1M tokens for one model."""
|
|
16
|
+
|
|
17
|
+
input: float
|
|
18
|
+
write_5m: float
|
|
19
|
+
write_1h: float
|
|
20
|
+
read: float
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Pricing:
|
|
24
|
+
"""Per-model unit prices. Unknown models return None, and callers then report tokens only."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, table: dict[str, Any]):
|
|
27
|
+
self.table = table
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def default(cls) -> "Pricing":
|
|
31
|
+
text = resources.files("cache_autopsy").joinpath("pricing.json").read_text(encoding="utf-8")
|
|
32
|
+
return cls(json.loads(text))
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_file(cls, path: str | Path) -> "Pricing":
|
|
36
|
+
"""Default table with the entries of ``path`` laid over it."""
|
|
37
|
+
try:
|
|
38
|
+
override = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
39
|
+
except FileNotFoundError:
|
|
40
|
+
raise InputFormatError(f"pricing file not found: {path}. Pass an existing JSON file.") from None
|
|
41
|
+
except UnicodeDecodeError:
|
|
42
|
+
raise InputFormatError(f"pricing file {path} is not valid UTF-8. Pass a UTF-8 JSON file.") from None
|
|
43
|
+
except OSError as exc:
|
|
44
|
+
raise InputFormatError(f"cannot read pricing file {path}: {exc.strerror or exc}.") from None
|
|
45
|
+
except json.JSONDecodeError as exc:
|
|
46
|
+
raise InputFormatError(f"pricing file {path} is not valid JSON: {exc}") from None
|
|
47
|
+
if not isinstance(override, dict):
|
|
48
|
+
raise InputFormatError(f"pricing file {path} must contain a JSON object like the bundled pricing.json.")
|
|
49
|
+
base = cls.default().table
|
|
50
|
+
merged = {**base, "_meta": {**base.get("_meta", {}), **override.get("_meta", {})}}
|
|
51
|
+
for family in ("anthropic", "openai"):
|
|
52
|
+
merged[family] = {**base.get(family, {}), **override.get(family, {})}
|
|
53
|
+
return cls(merged)
|
|
54
|
+
|
|
55
|
+
def rates(self, provider: str, model: str | None) -> Rates | None:
|
|
56
|
+
family = "anthropic" if provider == "anthropic" else "openai"
|
|
57
|
+
models = self.table.get(family, {})
|
|
58
|
+
matches = [k for k in models if model and model.startswith(k)]
|
|
59
|
+
if not matches:
|
|
60
|
+
return None
|
|
61
|
+
e = models[max(matches, key=len)]
|
|
62
|
+
try:
|
|
63
|
+
if family == "anthropic":
|
|
64
|
+
return Rates(e["input"], e["cache_write_5m"], e["cache_write_1h"], e["cache_read"])
|
|
65
|
+
mult = self.table.get("_meta", {}).get("openai_cache_write_multiplier", 1.25)
|
|
66
|
+
return Rates(e["input"], e["input"] * mult, e["input"] * mult, e["cached_input"])
|
|
67
|
+
except KeyError as exc:
|
|
68
|
+
raise InputFormatError(f"pricing entry for {model!r} lacks field {exc}.") from None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def hit_rate(usages: list[Usage]) -> float | None:
|
|
72
|
+
"""Cache reads divided by all input tokens (each provider's own total, see providers/)."""
|
|
73
|
+
total = sum(u.total_input for u in usages)
|
|
74
|
+
return sum(u.cache_read for u in usages) / total if total else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cacheable_tokens(provider: str, usage: Usage) -> int:
|
|
78
|
+
"""Tokens the previous call left in the cache (what the next call could have read)."""
|
|
79
|
+
if provider == "anthropic":
|
|
80
|
+
return usage.cache_read + usage.cache_write
|
|
81
|
+
if usage.cache_write:
|
|
82
|
+
return usage.cache_read + usage.cache_write
|
|
83
|
+
# OpenAI caches from 1024 tokens upward in 128-token steps (docs, checked 2026-11-09)
|
|
84
|
+
return usage.total_input // 128 * 128 if usage.total_input >= 1024 else 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def missed_cost(rates: Rates, missed: int, current: Usage, one_hour: bool) -> float:
|
|
88
|
+
"""What the missed tokens cost beyond what a cache read would have cost.
|
|
89
|
+
|
|
90
|
+
Missed tokens that the response reports as cache writes were billed at the write rate;
|
|
91
|
+
the remainder was billed as ordinary input.
|
|
92
|
+
"""
|
|
93
|
+
write_rate = rates.write_1h if one_hour else rates.write_5m
|
|
94
|
+
written = min(missed, current.cache_write)
|
|
95
|
+
extra = written * (write_rate - rates.read) + (missed - written) * (rates.input - rates.read)
|
|
96
|
+
return extra / 1_000_000
|
cache_autopsy/demo.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""`cache-autopsy demo`: run the analyzer on the bundled practice (mock) logs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from importlib import resources
|
|
5
|
+
|
|
6
|
+
from .cost import Pricing
|
|
7
|
+
from .records import read_jsonl
|
|
8
|
+
from .report import pct, usd
|
|
9
|
+
from .verdict import analyze_records
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def samples_dir():
|
|
13
|
+
return resources.files("cache_autopsy").joinpath("samples")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_demo() -> int:
|
|
17
|
+
print("NOTE: practice mock data (연습용 목(mock) 데이터). These are NOT real API logs; "
|
|
18
|
+
"usage numbers are invented to follow the providers' rules.")
|
|
19
|
+
print("The analyzer really renders, diffs and classifies the fake requests below.\n")
|
|
20
|
+
pricing = Pricing.default()
|
|
21
|
+
reports = {}
|
|
22
|
+
for label in ("broken", "fixed"):
|
|
23
|
+
path = samples_dir().joinpath(f"anthropic_{label}.jsonl")
|
|
24
|
+
with resources.as_file(path) as real:
|
|
25
|
+
reports[label] = analyze_records(read_jsonl(real), pricing)
|
|
26
|
+
print(f"{'session':<8} {'calls':>5} {'hit rate':>9} {'missed tokens':>14} {'missed cost':>12}")
|
|
27
|
+
for label, rep in reports.items():
|
|
28
|
+
s = rep.summary
|
|
29
|
+
print(f"{label:<8} {s['records']:>5} {pct(s['hit_rate']):>9} {s['missed_tokens']:>14,} {usd(s['missed_cost_usd']):>12}")
|
|
30
|
+
print("\nWhy 'broken' misses (cause: calls, missed tokens):")
|
|
31
|
+
for code, row in sorted(reports["broken"].summary["by_cause"].items(), key=lambda kv: -kv[1]["missed_tokens"]):
|
|
32
|
+
print(f" {code:<26} {row['pairs']} call(s) {row['missed_tokens']:,} tokens {usd(row['missed_cost_usd'])}")
|
|
33
|
+
print("\nTry: cache-autopsy analyze <your-log>.jsonl | cache-autopsy causes")
|
|
34
|
+
return 0
|
cache_autopsy/diff.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""First-divergence search and cause classification for two consecutive requests."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .model import Divergence, InputFormatError, ProviderSpec
|
|
8
|
+
from .providers import detect
|
|
9
|
+
from .render import byte_offset, dumps, first_diff, locate, render_sections
|
|
10
|
+
from .rules import VOLATILE
|
|
11
|
+
|
|
12
|
+
# Canonical section order across providers; each provider only fills the sections it has.
|
|
13
|
+
SECTION_ORDER = ("model", "tools", "system", "instructions", "messages", "input")
|
|
14
|
+
HISTORY_SECTIONS = ("messages", "input") # last sections: appending to them keeps the prefix intact
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _tool_name(tool: Any) -> str:
|
|
18
|
+
if isinstance(tool, dict):
|
|
19
|
+
return str(tool.get("name") or (tool.get("function") or {}).get("name") or dumps(tool))
|
|
20
|
+
return dumps(tool)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _canonical(value: Any) -> str:
|
|
24
|
+
return json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _volatile_codes(text_a: str, text_b: str) -> list[str]:
|
|
28
|
+
"""Volatile-value kinds whose matches differ between the two texts."""
|
|
29
|
+
found: list[str] = []
|
|
30
|
+
for code, pattern in VOLATILE:
|
|
31
|
+
if code not in found and set(pattern.findall(text_a)) != set(pattern.findall(text_b)):
|
|
32
|
+
found.append(code)
|
|
33
|
+
return found
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_append(name: str, va: Any, vb: Any) -> bool:
|
|
37
|
+
if name not in HISTORY_SECTIONS:
|
|
38
|
+
return False
|
|
39
|
+
if isinstance(va, list) and isinstance(vb, list):
|
|
40
|
+
return len(va) <= len(vb) and all(dumps(x) == dumps(y) for x, y in zip(va, vb))
|
|
41
|
+
if isinstance(va, str) and isinstance(vb, str):
|
|
42
|
+
return vb.startswith(va)
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _classify(name: str, va: Any, vb: Any, ta: str, tb: str, window_a: str, window_b: str) -> list[str]:
|
|
47
|
+
if va is None or vb is None:
|
|
48
|
+
return ["section_added_or_removed"]
|
|
49
|
+
if name == "tools" and isinstance(va, list) and isinstance(vb, list):
|
|
50
|
+
names_a, names_b = [_tool_name(t) for t in va], [_tool_name(t) for t in vb]
|
|
51
|
+
if sorted(names_a) == sorted(names_b) and names_a != names_b and \
|
|
52
|
+
_canonical(sorted(va, key=_tool_name)) == _canonical(sorted(vb, key=_tool_name)):
|
|
53
|
+
return ["tool_order_changed"]
|
|
54
|
+
if _canonical(va) == _canonical(vb):
|
|
55
|
+
return ["json_key_order_changed"]
|
|
56
|
+
return ["tool_definition_changed", *_volatile_codes(ta, tb)]
|
|
57
|
+
if name in ("system", "instructions"):
|
|
58
|
+
return _volatile_codes(ta, tb) or ["system_edited"]
|
|
59
|
+
if name in HISTORY_SECTIONS:
|
|
60
|
+
return ["history_edited", *_volatile_codes(window_a, window_b)]
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _structural(spec: ProviderSpec, a: dict, b: dict, ctx: int) -> tuple[Divergence | None, str | None]:
|
|
65
|
+
"""Earliest section that differs. Returns (divergence, section name)."""
|
|
66
|
+
sa, sb = spec.sections(a), spec.sections(b)
|
|
67
|
+
da, db = dict(sa), dict(sb)
|
|
68
|
+
ra, _, _ = render_sections(sa)
|
|
69
|
+
rb, spans_b, bounds_b = render_sections(sb)
|
|
70
|
+
offset = 0 # equal sections have equal length, so one running offset serves both renderings
|
|
71
|
+
for name in SECTION_ORDER:
|
|
72
|
+
va, vb = da.get(name), db.get(name)
|
|
73
|
+
if name not in da and name not in db:
|
|
74
|
+
continue
|
|
75
|
+
ta = dumps(va) if name in da else ""
|
|
76
|
+
tb = dumps(vb) if name in db else ""
|
|
77
|
+
if name in da and name in db and ta == tb:
|
|
78
|
+
offset += len(ta)
|
|
79
|
+
continue
|
|
80
|
+
inner = first_diff(ta, tb) or 0
|
|
81
|
+
at = offset + inner
|
|
82
|
+
# a new request that is shorter than the old one diverges where it ends: report the section
|
|
83
|
+
path = locate(spans_b, at, name) if at < offset + len(tb) else name
|
|
84
|
+
append = _is_append(name, va, vb)
|
|
85
|
+
if append and isinstance(va, list) and len(vb) > len(va):
|
|
86
|
+
# appended items: point at the first new member instead of the closing bracket
|
|
87
|
+
target = f"{name}[{len(va)}]"
|
|
88
|
+
start = next((sp[0] for sp in spans_b if sp[2] == target), None)
|
|
89
|
+
if start is not None:
|
|
90
|
+
at, path = start, target
|
|
91
|
+
window_a, window_b = ra[max(0, at - ctx): at + 2 * ctx], rb[max(0, at - ctx): at + 2 * ctx]
|
|
92
|
+
causes = ["append_only_ok"] if append else _classify(name, va, vb, ta, tb, window_a, window_b)
|
|
93
|
+
div = Divergence(
|
|
94
|
+
byte_offset=byte_offset(rb, at),
|
|
95
|
+
json_path=path,
|
|
96
|
+
reason=spec.section_reason.get(name, f"{name}_changed"),
|
|
97
|
+
causes=causes,
|
|
98
|
+
context_before=rb[max(0, at - ctx): at],
|
|
99
|
+
context_after=rb[at: at + ctx],
|
|
100
|
+
context_prev_after=ra[at: at + ctx],
|
|
101
|
+
prefix_intact=append,
|
|
102
|
+
)
|
|
103
|
+
return div, name
|
|
104
|
+
return None, None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _setting_divergence(spec: ProviderSpec, a: dict, b: dict, ctx: int) -> Divergence | None:
|
|
108
|
+
for (reason, label, path, va), (_, _, _, vb) in zip(spec.settings(a), spec.settings(b)):
|
|
109
|
+
if dumps(va) != dumps(vb):
|
|
110
|
+
return Divergence(
|
|
111
|
+
byte_offset=None, json_path=path, reason=reason, causes=[f"setting_changed:{label}"],
|
|
112
|
+
context_before="", context_after=dumps(vb)[:ctx], context_prev_after=dumps(va)[:ctx])
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def diff_requests(a: dict, b: dict, provider: str | None = None, context: int = 40) -> Divergence:
|
|
117
|
+
"""Compare two request bodies and report the first point where the cacheable prefix differs.
|
|
118
|
+
|
|
119
|
+
``a`` is the earlier request, ``b`` the later one. Offsets refer to a local rendering of the
|
|
120
|
+
prefix in the provider's documented order, not to the provider's private bytes.
|
|
121
|
+
"""
|
|
122
|
+
for label, req in (("first", a), ("second", b)):
|
|
123
|
+
if not isinstance(req, dict):
|
|
124
|
+
raise InputFormatError(f"the {label} request must be a JSON object, got {type(req).__name__}")
|
|
125
|
+
spec = detect(b, hint=provider)
|
|
126
|
+
struct, section = _structural(spec, a, b, context)
|
|
127
|
+
setting = _setting_divergence(spec, a, b, context)
|
|
128
|
+
if struct and section == "model":
|
|
129
|
+
return struct
|
|
130
|
+
if setting and spec.settings_first:
|
|
131
|
+
return setting
|
|
132
|
+
if struct and not struct.prefix_intact:
|
|
133
|
+
return struct
|
|
134
|
+
if setting:
|
|
135
|
+
return setting
|
|
136
|
+
if struct:
|
|
137
|
+
return struct
|
|
138
|
+
return Divergence(byte_offset=None, json_path=None, reason="unchanged", causes=["identical"], prefix_intact=True)
|
cache_autopsy/lint.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Single-request checks: patterns that will break caching on the next call."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .model import Finding
|
|
8
|
+
from .providers import detect
|
|
9
|
+
from .render import estimate_tokens, render_sections
|
|
10
|
+
from .rules import CAUSES, find_volatile
|
|
11
|
+
|
|
12
|
+
MAX_BREAKPOINTS = 4
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _strings(node: Any, path: str):
|
|
16
|
+
"""Yield (json path, string) for every string value in ``node``."""
|
|
17
|
+
if isinstance(node, str):
|
|
18
|
+
yield path, node
|
|
19
|
+
elif isinstance(node, dict):
|
|
20
|
+
for key, child in node.items():
|
|
21
|
+
if key != "cache_control":
|
|
22
|
+
yield from _strings(child, f"{path}.{key}" if path else key)
|
|
23
|
+
elif isinstance(node, list):
|
|
24
|
+
for i, child in enumerate(node):
|
|
25
|
+
yield from _strings(child, f"{path}[{i}]")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_ORDER = {"tools": 0, "system": 1, "messages": 2}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _position(path: str) -> tuple[int, int]:
|
|
32
|
+
"""Sort key of a JSON path inside the prefix: (section order, top-level index)."""
|
|
33
|
+
m = re.match(r"(\w+)(?:\[(\d+)\])?", path)
|
|
34
|
+
return (_ORDER.get(m.group(1), 3), int(m.group(2) or 0)) if m else (3, 0)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def lint_request(req: dict, provider: str | None = None, min_tokens: int = 1024) -> list[Finding]:
|
|
38
|
+
"""Return findings for one request body. Warnings and errors mean "fix before shipping"."""
|
|
39
|
+
spec = detect(req, hint=provider)
|
|
40
|
+
findings: list[Finding] = []
|
|
41
|
+
prefix_names = [n for n in ("tools", "system", "instructions") if req.get(n) is not None]
|
|
42
|
+
volatile: list[tuple[str, list[str]]] = [] # (path, codes) inside the stable part of the prefix
|
|
43
|
+
for name in prefix_names:
|
|
44
|
+
for path, text in _strings(req[name], name):
|
|
45
|
+
codes = find_volatile(text)
|
|
46
|
+
if codes:
|
|
47
|
+
volatile.append((path, codes))
|
|
48
|
+
# An OpenAI chat prompt keeps its system text inside messages: scan leading system/developer ones.
|
|
49
|
+
for i, msg in enumerate(req.get("messages") or []):
|
|
50
|
+
if isinstance(msg, dict) and msg.get("role") in ("system", "developer"):
|
|
51
|
+
for path, text in _strings(msg.get("content"), f"messages[{i}].content"):
|
|
52
|
+
codes = find_volatile(text)
|
|
53
|
+
if codes:
|
|
54
|
+
volatile.append((path, codes))
|
|
55
|
+
for path, codes in volatile:
|
|
56
|
+
for code in codes:
|
|
57
|
+
findings.append(Finding(code, "warning", path,
|
|
58
|
+
f"a value that changes between calls sits in the cached prefix ({path}).",
|
|
59
|
+
CAUSES[code][2]))
|
|
60
|
+
|
|
61
|
+
markers = spec.breakpoints(req)
|
|
62
|
+
if spec.name == "anthropic":
|
|
63
|
+
if not markers:
|
|
64
|
+
findings.append(Finding("no_cache_marker", "info", "", "no cache_control marker found; nothing will be cached.",
|
|
65
|
+
CAUSES["no_cache_marker"][2]))
|
|
66
|
+
explicit = [m for m in markers if m != "cache_control"]
|
|
67
|
+
if len(markers) > MAX_BREAKPOINTS:
|
|
68
|
+
findings.append(Finding("too_many_breakpoints", "error", markers[MAX_BREAKPOINTS],
|
|
69
|
+
f"{len(markers)} cache markers found; the limit is {MAX_BREAKPOINTS}.",
|
|
70
|
+
CAUSES["too_many_breakpoints"][2]))
|
|
71
|
+
for m in explicit:
|
|
72
|
+
# a marker caches everything before it: any changing value at or before it breaks the key
|
|
73
|
+
culprit = next((path for path, _ in volatile if _position(path) <= _position(m)), None)
|
|
74
|
+
if culprit:
|
|
75
|
+
findings.append(Finding("breakpoint_on_volatile_block", "warning", m,
|
|
76
|
+
f"{m} is cached, but the prefix up to it contains a changing value "
|
|
77
|
+
f"({culprit}), so the cache key never repeats.",
|
|
78
|
+
CAUSES["breakpoint_on_volatile_block"][2]))
|
|
79
|
+
|
|
80
|
+
text, _, _ = render_sections(spec.sections(req))
|
|
81
|
+
if markers or spec.name != "anthropic":
|
|
82
|
+
est = estimate_tokens(text)
|
|
83
|
+
if est < min_tokens:
|
|
84
|
+
findings.append(Finding("below_min_tokens", "info", "",
|
|
85
|
+
f"estimated ~{est} tokens (characters/4) is below {min_tokens}; "
|
|
86
|
+
"providers skip caching under their per-model minimum.",
|
|
87
|
+
CAUSES["below_min_tokens"][2]))
|
|
88
|
+
return findings
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def has_problems(findings: list[Finding]) -> bool:
|
|
92
|
+
return any(f.severity in ("warning", "error") for f in findings)
|