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.
@@ -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
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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)