Prompt caching¶
Stability: chat caching configuration belongs to the chat contract.
Stored-cache resources (cache_*, CachedPrefix) are provisional in 1.0:
they ship, but may change incompatibly in 1.x with a contract change entry.
See release scope.
Problem — Your requests share a large fixed prefix: a system prompt,
a policy document, tool schemas. Without caching you pay full input price
for that prefix on every call. Each provider has its own caching API —
cache_control blocks, prompt_cache_key, cachedContents — and lm15
folds them into one CacheConfig.
Keys loaded as in recipe 01.
Recipe¶
Build a prefix big enough to matter. Anthropic and OpenAI refuse to cache prompts under 1024 tokens (~2048 for Haiku), so a short system prompt caches nothing on any provider.
from lm15 import CacheConfig, Config, LMRouter, Message, Request
from lm15.models import InferencePricing
manual = "\n".join(
f"Policy {i}: refunds over ${i * 10} require approval level {i % 5 + 1}; "
f"log every decision under ticket class R{i:03d} before replying."
for i in range(1, 161)
)
router = LMRouter()
print(f"~{len(manual) // 4} tokens of policy manual")
~4625 tokens of policy manual
Attach CacheConfig() to the request. On Anthropic this places a
cache_control: {"type": "ephemeral"} marker on the system block; the
first call writes the cache:
req = Request(
model="claude-sonnet-4-5",
system=f"You are a support agent. Follow this policy manual.\n{manual}",
messages=(Message.user("Which approval level for a $90 refund? One sentence."),),
config=Config(max_tokens=100, cache=CacheConfig()),
)
first = router.complete(req)
print(first.text)
print("write:", first.usage.cache_write_tokens, "read:", first.usage.cache_read_tokens)
A $90 refund requires approval level 5 according to Policy 9.
write: 4873 read: 0
The second call within the TTL (5 minutes by default) reads the prefix
back at one tenth of input price. input_tokens counts the whole prompt,
cached part included, on every provider; only the 20 uncached tokens
bill at the full rate:
second = router.complete(req)
print("write:", second.usage.cache_write_tokens, "read:", second.usage.cache_read_tokens)
print("input:", second.usage.input_tokens)
write: 0 read: 4873
input: 4893
(Anthropic's own usage.input_tokens is 20 here: it counts only the
tokens after the cache breakpoint. That number is still in
second.provider_data["usage"]; lm15's input_tokens means the same
thing on every provider. Before lm15 2.0 it was the provider's own
number, so this printed 20.)
OpenAI caches automatically and for free — no markers, no write charge.
CacheConfig(key=...) sends prompt_cache_key, which improves hit
rates by routing same-key requests to the same machine. Note the
differences: cache_write_tokens is None (this model reports no
writes), and the cached span rounds down to a 128-token boundary:
oreq = Request(
model="gpt-4.1-mini",
system=f"You are a support agent. Follow this policy manual.\n{manual}",
messages=(Message.user("Which approval level for a $90 refund? One sentence."),),
config=Config(max_tokens=100, cache=CacheConfig(key="support-agent-v2")),
)
for call in ("first", "second"):
usage = router.complete(oreq).usage
print(f"{call}: write={usage.cache_write_tokens} read={usage.cache_read_tokens} input={usage.input_tokens}")
first: write=None read=0 input=4095
second: write=None read=3968 input=4095
Does it pay? InferencePricing.estimate() prices a Usage at list
prices. With Anthropic's rates (reads 0.1×, writes 1.25× input price),
the write premium on call one is recovered before call two finishes:
from lm15 import Usage
sonnet = InferencePricing(
input_per_million=3.0, output_per_million=15.0,
cache_read_per_million=0.30, cache_write_per_million=3.75,
)
cold = sonnet.estimate(Usage(input_tokens=4893, output_tokens=50, cache_read_tokens=0, cache_write_tokens=0))
write = sonnet.estimate(Usage(input_tokens=4893, output_tokens=50, cache_read_tokens=0, cache_write_tokens=4873))
warm = sonnet.estimate(Usage(input_tokens=4893, output_tokens=50, cache_read_tokens=4873, cache_write_tokens=0))
print(f"no cache: ${cold:.5f} per call")
print(f"cache write: ${write:.5f} first call")
print(f"cache read: ${warm:.5f} every call after")
no cache: $0.01543 per call
cache write: $0.01908 first call
cache read: $0.00227 every call after
How it works¶
CacheConfig lives on Config.cache and carries four fields: mode
("auto" or "off"), retention ("short"/"long"), key, and
prefix_until_index. What goes on the wire is per-provider:
- Anthropic — explicit breakpoints. With any non-off
CacheConfig, the adapter wrapssystemin a content block withcache_control: {"type": "ephemeral"}.retention="long"adds"ttl": "1h"(billed at 2× input instead of 1.25×).prefix_until_index=Nputs a second marker on messageN, so a stable conversation history caches along with the system prompt. Usage reports bothcache_read_input_tokensandcache_creation_input_tokens, mapped toUsage.cache_read_tokens/cache_write_tokens; both are added intoUsage.input_tokens, since Anthropic counts them beside its owninput_tokens. - OpenAI — implicit. Caching happens server-side on any prompt over
1024 tokens whether you send
CacheConfigor not. lm15 forwardskeyasprompt_cache_keyandretention="long"asprompt_cache_retention: "24h". Reads surface asinput_tokens_details.cached_tokens; writes asinput_tokens_details.cache_write_tokenson the models that report them (gpt-5.6 and later). - Gemini — a separate resource. The adapter hashes the prefix
(everything but the last message, or up to
prefix_until_index), POSTs it once tocachedContents, remembers the returned id in the LM instance, and sendscachedContent: <id>on subsequent payloads. Reads surface ascachedContentTokenCount.
Numbers carry the zeros-vs-absent distinction:
cache_write_tokens=None means "not reported", 0 means "reported
zero". InferencePricing.estimate() never treats None as zero: when
an unknown count could change the price it returns None rather than a
smaller number (a cache count may be unknown only when the catalog says
those tokens cost the same as plain input). It also returns None for
calls with audio tokens, and knows nothing of batch, flex or priority
pricing.
The cache itself is provider-side state. lm15 holds no cache, sets no TTLs of its own, and does not retry on cache misses — a miss is not an error, it is full price.
Variations¶
- Gemini, live. Gemini needs at least two messages before the
adapter has a prefix to split off, and the cachedContents entry is
created before the first generate call — so even call one reports a
read. The Gemini LM is built lazily and cached by the router
(see using the router), so the remembered
cache id survives across
router.complete()calls:
greq = Request(
model="gemini-3-flash-preview",
system=f"You are a support agent. Follow this policy manual.\n{manual}",
messages=(
Message.user("Read the manual and stand by."),
Message.assistant("Standing by."),
Message.user("Which approval level for a $90 refund? One sentence."),
),
config=Config(max_tokens=200, cache=CacheConfig()),
)
for call in ("first", "second"):
usage = router.complete(greq).usage
print(f"{call}: read={usage.cache_read_tokens} input={usage.input_tokens}")
first: read=5087 input=5101
second: read=5087 input=5101
- Opting out.
CacheConfig(mode="off")suppresses all cache hints. On Anthropic and Gemini that disables caching; on OpenAI the implicit server-side cache still applies — you cannot turn it off from the client. - Keep the prefix stable. Caches match byte-identical prefixes. A timestamp in the system prompt, reordered tools, or a changed temperature (on OpenAI, any payload difference before the suffix) breaks the match. Put volatile content in the last user message.
- When it pays. Anthropic: two calls within the TTL break even; prompts under the 1024-token floor never cache. OpenAI: always — reads are discounted and writes are free. Gemini: cached tokens are discounted but storage bills per token-hour, so short-lived caches on rarely-reused prefixes can cost more than they save.
- Async mirror.
AsyncLMRouterbehaves identically —await router.complete(req)with the sameCacheConfig; the Gemini cachedContents POST goes through the async transport.
See also¶
- 01 — Your first request —
Usagefields and the zeros-vs-absent rule - 02 — Conversations — stable history prefixes worth caching
- 03 — System prompts — the prefix you cache most often
- 06 — Function tools — tool schemas count toward the cached prefix
- Using the router — per-provider LM reuse, which keeps Gemini cache ids alive