Skip to main content

promptcachelint

Explain why your LLM prompt cache missed.

Prompt caching on Anthropic and OpenAI is a prefix match: one changed byte anywhere before the cache marker and everything after it is billed again at full price. The providers tell you that it happened (cache_read_input_tokens: 0) but not where. promptcachelint records your requests, diffs each one against the previous request of the same conversation, and names the block, the offset and the characters that broke the prefix, plus the usual suspects it can see without history: timestamps in the system prompt, a missing marker, a prefix below the model's minimum.

promptcachelint: 3 requests in 1 session(s); prompt tokens 4880 (read 0, write 4806, uncached 74); hit ratio 0.0%; 2 prefix break(s), ~3112 tokens lost (estimate)
  (sessions grouped automatically by first message; use promptcachelint.session(id) for exact grouping)

session 600a924e2101 [anthropic] 3 req, hit ratio 0.0%, 2 break(s)
  #0  claude-opus-5  segments=2 cacheable=2  in=24 read=0 write=1601
      ! CL012 iso-datetime inside the cacheable prefix at system[0]
        → Timestamps change every request. Move them after the last breakpoint or drop them.
  #1  claude-opus-5  segments=4 cacheable=4  in=25 read=0 write=1602
      ✗ prefix broken at system[0] +6190 (~1553 tokens lost, estimate)
        was: '…ime: 2026-09-20T10:15'
        now: '…ime: 2026-09-20T10:16'
  #2  claude-opus-5  segments=6 cacheable=6  in=25 read=0 write=1603
      ✗ prefix broken at system[0] +6190 (~1559 tokens lost, estimate)
        was: '…ime: 2026-09-20T10:16'
        now: '…ime: 2026-09-20T10:17'

Zero dependencies. Python 3.11+. Anthropic Messages API, OpenAI Chat Completions and Responses (with the full history in the request; when previous_response_id keeps the conversation server-side, the request body holds only the new turn and there is no prefix to analyse).

Install

pip install promptcachelint            # core: explicit recording, analysis, CLI
pip install "promptcachelint[httpx2]"  # + transport that records SDK traffic automatically

Quick start

The SDKs accept an http_client. Keep their own client class (it carries the SDK's connection limits and timeouts) and wrap only the transport:

import anthropic, httpx2
import promptcachelint
from promptcachelint.transport import wrap_transport

recorder = promptcachelint.Recorder(
    promptcachelint.LogWatcher(),               # warn in the log the moment a prefix breaks
    promptcachelint.JsonlSink("trace.jsonl"),   # keep a trace for the offline report
)
claude = anthropic.Anthropic(
    http_client=anthropic.DefaultHttpxClient(
        transport=wrap_transport(httpx2.HTTPTransport(), recorder)
    )
)
# openai: openai.OpenAI(http_client=openai.DefaultHttpxClient(transport=wrap_transport(...)))
# async:  anthropic.DefaultAsyncHttpxClient(transport=wrap_transport(httpx2.AsyncHTTPTransport(), recorder))

# ... run your agent as usual ...

print(promptcachelint.analyze(recorder.records).to_text())

Then, or on a trace from production:

promptcachelint report trace.jsonl              # human-readable; broken lines are skipped and counted
promptcachelint report trace.jsonl --json       # machine-readable
promptcachelint report trace.jsonl --fail-on CL001,CL002   # non-zero exit for CI
promptcachelint lint request.json --provider anthropic     # static checks on one body
promptcachelint codes

No SDK, or a gateway of your own? Record explicitly:

recorder.record("anthropic", body, response=response_json)          # non-streaming
recorder.record("anthropic", body, sse_events=events, stream=True)  # streaming

What it reports

Code Meaning
CL001 Content inside the previously cached prefix changed. Block, offset, before/after excerpt, estimated tokens lost. Confirmed by the diff.
CL002 Tool definitions changed between requests (tools render first: everything is invalidated).
CL003 A top-level parameter changed: model, thinking, output_config.effort, tool_choice, speed, prompt_cache_key.
CL004 The last breakpoint moved earlier than in the previous request.
CL005 Prefix (or a marked tools+system head shared across requests) was identical and within TTL, but the provider read nothing. Suspects outside the payload. Informational on OpenAI, whose cache is best-effort.
CL006 The gap since the previous request exceeds the cache TTL (Anthropic: the longest marker's, 5 min or 1 h; OpenAI about 10 min).
CL007 More than 20 positions appended in one turn; Anthropic's breakpoint lookback window is 20 (a run of parallel tool_use blocks is one position, as is a run of tool_result blocks).
CL010 No cache_control anywhere on a prompt large enough to cache.
CL011 Cacheable prefix below the model's minimum (512 to 4096 tokens depending on model). Estimate.
CL012 Timestamp / UUID / random-looking id / "today is" inside the cacheable prefix. A suspicion from patterns; CL001 is the confirmation.
CL013 More than 4 cache_control slots (explicit markers plus the top-level automatic one).
CL014 Requests with the same, unmarked tools+system write cache and never read it: the only marker sits after per-request content.

What is exact and what is estimated

The diff runs on a canonical rendering of the request: each tool, system block and message block becomes one segment; text blocks keep their text as is, structured blocks are serialized as JSON with sorted keys, and cache markers are stripped (the marker moving forward is not an invalidator). Offsets are character offsets into that block's own text, so they point at the character you wrote. Key order inside JSON blocks is not compared, because the provider parses the JSON before hashing.

Costs come from the provider's own usage fields and are exact. Only "tokens that could have been read" is an estimate (about 4 characters per token) and is labelled as such everywhere.

Sessions

Consecutive requests are diffed within a session. Two modes:

  • Explicit: with promptcachelint.session("conv-42"): around the calls, or session_id= on recorder.record. Reliable; use it in production.
  • Automatic (default): a request continues the session whose first message block it repeats byte for byte, when its message history is the previous history plus new turns (or an exact retry). Growth of the history is the signature of a continuation, so a tool reorder or a rewritten system prompt mid-conversation stays in the session and is reported. Interleaved conversations that share a system prompt stay apart, and so do two users who both open with "hi" from their second turn on. Two rewrites that would otherwise look like new conversations are recognised and reported as prefix breaks with their own hint: a sliding history window (oldest turns dropped; detected up to 16 blocks deep) and an edited history (old tool results truncated, a turn summarised; detected when most of the previous blocks are still there in order). The report says when grouping was automatic; explicit sessions remain the reliable choice in production.

Privacy and cost

A trace contains full prompts. Before it leaves the machine, pass a redactor to the sink:

from promptcachelint.redact import strip_media, hash_text
promptcachelint.JsonlSink("trace.jsonl", redact=strip_media)  # drop base64 images/PDFs, keep text
promptcachelint.JsonlSink("trace.jsonl", redact=hash_text)    # keep structure and sizes only

With hash_text every text becomes its SHA-256 digest repeated or cut to the same length in characters, so token estimates and size-based findings stay right, different texts stay different, and the report still says which block changed and by how much, but offsets and excerpts point into placeholders.

Memory. Recorder keeps the last 10 000 records by default (keep=; None for unlimited, 0 for a pure live setup that only feeds sinks). LogWatcher runs the analyzer without history: one request per session is remembered for the diff, and sessions idle for six hours are dropped, so a long-running process holds memory proportional to its active conversations, not to its lifetime traffic. The offline report (analyze, promptcachelint report) keeps everything for the trace it is given.

CPU. Recording runs on the caller's thread right after the response body is consumed: one pass over the request body plus an O(1) session lookup. Recorder is thread-safe. On a latency-critical async path with very large prompts, record from a worker instead of the transport.

In tests

from promptcachelint.testing import assert_cache_stable

def test_agent_keeps_its_cache(recorder):
    run_agent(http_client=...)
    assert_cache_stable(recorder.records)   # fails with the rendered report on CL001/002/010/013

Custom gateways

Register an adapter for a proxy that speaks a known dialect on another host:

from promptcachelint.providers import AnthropicProvider, register_provider

class Gateway(AnthropicProvider):
    name = "gateway"
    def matches(self, url: str) -> bool:
        return "llm.internal" in url

register_provider(Gateway())

Not in scope (yet)

Rewriting requests or placing markers for you, Gemini, semantic caches, a proxy server. The diagnosis has to be right before the fix can be automatic.

License

MIT

Metadata

Release files for promptcachelint 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for promptcachelint 0.1.0
File Size Uploaded
promptcachelint-0.1.0.tar.gz 48.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for promptcachelint 0.1.0
File Interpreter ABI Platform
promptcachelint-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 91.1 kB

Release files / promptcachelint-0.1.0.tar.gz

Download URL promptcachelint-0.1.0.tar.gz
Size 48.5 kB
Tags Source
SHA-256 checksum
How to use checksums
cdf2cb0e1a2ed908cc392b42f9dfadbce3fe9a9e3eab4eaf48b2c78a2d0583d3
BLAKE2b-256 checksum
How to use checksums
cc0d0a445dee1e614a6bd0743adbf58a5216f25b754c24071e248a623dbc4965
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.

Transparency log

Release files / promptcachelint-0.1.0-py3-none-any.whl

Download URL promptcachelint-0.1.0-py3-none-any.whl
Size 42.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
91e2bf0663992668fe5503bbc4eb7fe7d6206306010b9de16e34cbbc6919e034
BLAKE2b-256 checksum
How to use checksums
d1522c50b8fabe7726f7f432d5b00a1b89d50270a30b6ae1b0abb4914138bf96
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page