Cache¶
agent_adapter.cache saves money and time at four layers. Backends (CachePort: async
get, set(key, value, ttl), delete) store bytes; the typed caches put versioned JSON
envelopes in them (never pickle); the prompt layout and provider plug-ins make the
providers' own prefix caches hit.
flowchart LR
subgraph ours["Our caches (CachePort)"]
T["ToolResultCache<br/>tool + args + scope"]
L["LLMResponseCache<br/>provider + model + request + fields"]
end
T & L --> P[("MemoryCache / RedisCache")]
subgraph theirs["Provider prefix caches"]
Y["PromptLayout<br/>stable prefix first"] --> G["plug-ins<br/>OpenAI · Gemini · Anthropic"]
end
G --> K["SDK kwargs"]
Backends¶
cache = MemoryCache(max_entries=1000) # LRU; TTLs on the monotonic clock
await cache.set("greeting", b"hello", ttl=60)
print(await cache.get("greeting"), await cache.get("missing"))
def shared_cache() -> RedisCache:
"""Needs the redis extra; connects lazily, one client per event loop."""
return RedisCache("redis://localhost:6379/0", prefix="myapp:", socket_timeout=2.0)
MemoryCache |
RedisCache (extra redis) |
|
|---|---|---|
| Constructor | (*, max_entries=10_000, clock=time.monotonic) |
(url=None, *, client_factory=None, prefix="", socket_timeout=5.0, retries=3) |
| Storage | LRU OrderedDict in this process; thread-safe |
redis.asyncio, one client per event loop (LoopLocal) |
| TTL | seconds on clock; when full, expired entries are purged (at most once per max_entries sets), then LRU |
SET ... EX ceil(ttl) |
ttl <= 0 (incl. -inf) / inf, nan |
deletes the key / never expires | deletes the key / never expires |
| Failures | none | RedisError → WARNING, a miss / no-op; logs name keys by an 8-character digest, never the URL |
| Errors | ConfigError for max_entries < 1 |
ConfigError unless exactly one of url / client_factory, or for a malformed URL; MissingDependency without redis |
A URL client uses socket_timeout for connect and read and retries connection errors and
timeouts retries times with jittered backoff. client_factory builds your own client,
once per loop. run_sync closes each loop's client; await cache.aclose() the current one.
LLM response cache¶
llm_cache = LLMResponseCache(MemoryCache(), ttl=3600)
request = LLMRequest(messages=(Message(role="user", content="Capital of France?"),))
answer = LLMResponse(
provider="openai",
model="gpt-4.1",
text="Paris",
message=Message(role="assistant", content="Paris"),
latency_ms=640.0,
)
where: dict[str, Any] = {
"provider": "openai",
"model": "gpt-4.1",
"fields": {"api": "responses"},
}
await llm_cache.set(request, answer, **where)
hit = await llm_cache.get(request, **where)
assert hit is not None # a miss returns None
print(hit.text, hit.cached, hit.latency_ms)
| Rule | Detail |
|---|---|
| Key | {ns}:llm:v1:{provider}:{model}:{digest} over the request (minus stream, stage, response_cache, metadata) and fields (the port's cache_key_fields(), incl. its plug-in options) |
| Hit | the stored LLMResponse with cached=True, latency_ms=0 |
| Not stored | finish_reason == "error", responses that are already hits, non-JSON parsed values |
| Opt out | LLMRequest(response_cache=False) is neither read nor stored |
| Failures | any backend exception → WARNING, a miss / not stored |
Prompt layout¶
Providers cache the longest repeated prefix. PromptLayout keeps everything stable in the
system turn and everything per-request in the last user turn.
layout = PromptLayout(
static="You answer customer-support questions in one sentence.",
sections={"tools": "Tools: search_articles, shipping_rate."},
dynamic="Customer plan: premium.",
)
for message in layout.messages("What is the returns policy?"):
print(message.role, message.cache_breakpoint, repr(message.content))
| Member | Returns |
|---|---|
system_text() |
static, then each non-empty section, joined by blank lines |
messages(user=None, *, history=()) |
system turn (cache_breakpoint=True), history, then dynamic and user as one user turn; empty parts are left out |
prefix_digest() |
digest(system_text()); changes exactly when the cacheable prefix changes |
with_static(text), with_section(name, text) |
a new layout (sections keep their position when replaced) |
Provider plug-ins¶
agent_adapter.cache.providers holds one PromptCachePlugin per provider caching style.
A model port calls await plugin.apply(request, kwargs, model=..., client=...) and sends
the returned kwargs. Plug-ins never import an SDK, never edit the kwargs in place, keep
values the kwargs already hold, and never raise for an unsupported model (they log
DEBUG and return the kwargs unchanged).
own_caps = { # or caps=Prices.default().caps, the packaged table
"gpt-4.1": ModelCaps(prompt_cache_key=True, prompt_cache_retention_24h=True),
"claude-sonnet": ModelCaps(cache_control=True),
}
def caps(model: str) -> ModelCaps:
return own_caps.get(model, ModelCaps()) # an unknown model gets every flag off
request = LLMRequest(messages=layout.messages("What is the returns policy?"))
openai = OpenAIPromptCache(retention="24h", caps=caps)
out = await openai.apply(request, {"model": "gpt-4.1"}, model="gpt-4.1", client=None)
print(sorted(out))
anthropic = AnthropicPromptCache(ttl="1h", caps=caps)
kwargs = {"system": layout.system_text(), "messages": []}
out = await anthropic.apply(request, kwargs, model="claude-sonnet", client=None)
print(out["system"][-1]["cache_control"])
unknown = await anthropic.apply(request, kwargs, model="other", client=None)
print(unknown == kwargs) # no capability, no change
| Plug-in | Sets | Needs (ModelCaps) |
|---|---|---|
OpenAIPromptCache(*, key=None, retention=None, caps=None) |
prompt_cache_key (default: digest of the system text), prompt_cache_retention (in_memory / 24h) |
prompt_cache_key; 24h also prompt_cache_retention_24h |
OpenAIPromptCacheV2(*, mode="implicit", ttl="30m", key=None, caps=None) |
prompt_cache_options={mode, ttl}, prompt_cache_key; in explicit mode prompt_cache_breakpoint on each cache_breakpoint message |
prompt_cache_options |
GeminiExplicitCache(*, ttl_s=3600, store=None, caps=None) |
config.cached_content; moves system_instruction, tools, tool_config into the cache |
explicit_cache, prefix ≥ min_cache_tokens (chars / 4) |
GeminiImplicitCache() |
nothing: Gemini 2.5+ caches repeated prefixes itself | — |
AnthropicPromptCache(*, ttl="5m", on=("system", "tools"), caps=None) |
cache_control={"type": "ephemeral", "ttl"} on the last system block, the last tool, and ("breakpoints") each cache_breakpoint message |
cache_control |
Capabilities. caps is a Callable[[str], ModelCaps] (agent_adapter.models.ModelCaps).
Without it the flags come from the packaged price table (Prices.default().caps); an unknown
model gets ModelCaps(), so every gated parameter stays off and a plug-in never sends one a
model would reject. Pass your own function or caps=Prices.load(...).caps. key_fields()
returns the constructor options (not the computed key); ports add them to the LLM cache key.
Anthropic. At most four cache_control markers per request (the API limit), counting
the host's; the latest win, as each caches the whole prefix before it. Host markers with
another TTL leave the request unchanged (longer TTLs must come first). Messages are matched
to wire blocks by content (a reply split into text blocks: the last), so turns may merge.
OpenAI v2. In explicit mode breakpoints go on user, system and tool-result text
blocks (Chat content, Responses content / function_call_output.output); assistant
outputs and Responses instructions cannot. At most four (host ones count); the latest win.
With none placed, it falls back to implicit (mode omitted), one WARNING per plug-in.
Gemini explicit cache¶
sequenceDiagram
participant P as port
participant G as GeminiExplicitCache
participant S as store (CachePort)
participant API as client.aio.caches
P->>G: apply(request, kwargs, model, client)
G->>S: get {ns}:gcache:v1:{model}:{prefix_digest}
alt name stored
S-->>G: cachedContents/…
else first use (one creation in flight per key; waiters share it)
G->>API: create(model, system_instruction, tools, ttl)
API-->>G: name, usage_metadata
G->>S: set name (ttl_s minus a margin)
G-->>G: ledger: api entry "{model}:cache_storage"
end
G-->>P: config without the prefix, cached_content=name
storedefaults to a privateMemoryCache; a sharedRedisCachelets processes reuse one cached content. Use one store (or prefix) per Gemini project / API key: names are only valid there. A name is forgottenmin(60 s, 10 %)before Gemini expires it.- Each creation records
LedgerEntry(kind="api", provider="gemini", model=f"{model}:cache_storage", units=tokens × ttl_s / 3600 / 1e6)throughcurrent_context().ledger(skipped without a context or ledger). The full TTL is billed up front;metadataholds the cache name and token count. - A failed
caches.createlogs oneWARNING; requests go out unchanged and creation is not retried formin(ttl_s, 300 s).
Files¶
| File | Does |
|---|---|
cache/memory.py, redis.py |
MemoryCache, RedisCache (imports redis lazily) |
cache/llm_cache.py, layout.py |
LLMResponseCache, PromptLayout |
cache/providers/ |
the plug-ins; _base.py holds caps lookup and wire-block matching |
cache/keys.py, codec.py, tool_cache.py |
keys, envelopes and the tool-result cache (Runtime) |
This page's code is examples/cache.py; the test suite runs it.