Skip to content

feat(models): add prompt caching for the Anthropic provider (promptCaching, cacheTTL) - #2788

Open
teemow wants to merge 2 commits into
kagent-dev:mainfrom
teemow:feat/anthropic-prompt-caching
Open

feat(models): add prompt caching for the Anthropic provider (promptCaching, cacheTTL)#2788
teemow wants to merge 2 commits into
kagent-dev:mainfrom
teemow:feat/anthropic-prompt-caching

Conversation

@teemow

@teemow teemow commented Sep 10, 2026

Copy link
Copy Markdown

Closes #2787. Picks up the Anthropic half of #1866, whose Bedrock half shipped in #1940.

Problem

A declarative agent re-sends its full history on every model call. With the Anthropic provider nothing is marked cacheable today: promptCaching exists only on BedrockConfig, and neither runtime sends cache_control, so the whole prefix (system prompt, tool definitions, previous turns) is billed at full input price on every call.

Measured on kagent 0.10.1 on an internal installation, through a gateway that logs gen_ai.usage.* per model call (162 calls over 24 h, three agents, claude-sonnet-4-6 via the Anthropic provider): cache_read.input_tokens and cache_creation.input_tokens were 0 on every call. One incident-investigation session of a Python (kagent-app) agent made 119 model calls whose input grew from 4,423 to 190,809 tokens per call (average 93 k), i.e. roughly 11 M input tokens billed for one session, most of it the same prefix over and over; a Go (golang-adk) agent in the same window went 3.6 k → 93 k over 41 calls. Anthropic's prompt caching turns that repeat into cache reads at a fraction of the input price. Full numbers in #2787.

Change

  • spec.anthropic.promptCaching (bool, default false) and spec.anthropic.cacheTTL ("5m" | "1h", default "5m") on ModelConfig, with the same names, defaults and doc shape as the Bedrock fields, wired through adk.Anthropic (prompt_caching, cache_ttl) to both runtimes. CRDs regenerated (make controller-manifests).
  • When enabled, every Messages API request carries three cache_control breakpoints: the last tool definition, the last system prompt block, and the last content block of the latest conversation turn. Anthropic renders tools → system → messages and caches the prefix up to each breakpoint, so each call of an agent loop reads its whole previous history from the cache and writes only the new turn. That is three of the four breakpoints Anthropic allows; the conversation breakpoint skips thinking blocks, which Anthropic refuses to cache.
  • Go runtime (go/adk/pkg/models/anthropic_adk.go): the request shaping moves into buildAnthropicParams so it can be unit-tested, markAnthropicCacheBreakpoints sets the markers, and usage now maps cache_read_input_tokens to CachedContentTokenCount and folds cache read/creation tokens into PromptTokenCount (the GenAI shape treats cached as a breakdown of prompt, which is also how google-adk maps Anthropic usage on the Python side), so the UI's usage view shows the effect. Streaming and non-streaming.
  • Python runtime (KAgentAnthropicLlm): wraps the SDK client's messages resource with PromptCachingMessages, which marks the same three breakpoints before each create call. That is the one seam google-adk's AnthropicLlm hands its request through on both the streaming and the non-streaming path, so the request builder is not duplicated. google-adk ≥ 2.x already maps cache_read_input_tokens into cached_content_token_count; a regression guard for that is added.
  • Claude harness compiler: treats the CRD-defaulted cacheTTL: "5m" like the Bedrock one (without this every spec.anthropic block would now fail the "no options beyond baseUrl" check, because the CRD defaults the field whenever the block is present) and keeps rejecting promptCaching: true, since Claude Code caches natively.
  • Helm: providers.anthropic.config.promptCaching / cacheTTL documented in values.yaml; the chart already renders config into the provider block.

Design notes: opt-in like the Bedrock knob. A per-model minimum cacheable prefix applies (1024–4096 tokens; below it the markers are silently ignored) and 5-minute cache writes cost 1.25× normal input, so a prefix has to be read at least once to pay off. One knob enables all three breakpoints: the moving conversation breakpoint is what makes agent loops cheap, and a separate switch for it would be extra API surface for no realistic configuration. This is complementary to compaction (#2728): compaction bounds the growth of the prefix, caching removes the per-call repeat cost.

Testing

  • Go: go test ./adk/... ./api/... ./core/internal/translator/..., make -C go lint. New anthropic_adk_test.go asserts the wire body (as the SDK serializes it) carries exactly three markers at the expected positions for the default, "5m" and "1h" TTLs, none when disabled, the trailing-tool-result and no-tools/no-system cases, the usage fold, and drives both the SSE streaming and the non-streaming path through an httptest server. Plus agent wiring and config deserialization, translator wiring, and Claude compiler accept (cacheTTL: "5m") / reject (promptCaching) cases.

  • Python: pytest packages/kagent-adk/tests/unittests (228 passed; test_grpc_health fails locally only because port 8081 is taken on my machine). New tests for mark_prompt_cache_breakpoints (positions, thinking blocks skipped, no mutation of caller objects), the client wrapping, and end-to-end through google-adk's request builder with the SDK client mocked, including the usage-mapping guard.

  • Against the Anthropic API (claude-sonnet-4-6, ~2.7 k-token system prompt + 2 tools, three consecutive agent-loop calls alternating non-streaming/streaming, both runtimes, values are prompt_token_count / cached_content_token_count as reported by the runtime):

    runtime promptCaching call 1 call 2 call 3
    Go off 2739 / 0 2751 / 0 2763 / 0
    Go on, 5m 2739 / 0 (cache write) 2751 / 2736 2763 / 2748
    Python off 2740 / 0 2756 / 0 2772 / 0
    Python on, 5m 2740 / 0 (cache write) 2756 / 2737 2772 / 2753

    After the first call ~99 % of every prompt is served from the cache and surfaces as cached tokens.

  • Not run here: a kind-based run of an AgentTemplate on main (the kind flow on main has Substrate disabled by default). The kind end-to-end run was done on the release/v0.10.x backport, feat(models): add prompt caching for the Anthropic provider (backport to release/v0.10.x) #2789, which runs agents as Deployments: Go and Python declarative agents with the built-in Kubernetes tools (~14 k-token prefix), three turns through the controller's A2A endpoint — every model call after the first reads ~99.5 % of its prompt from the cache (e.g. Go 15665 / 14257 cached on the tool-result follow-up, 16337 / 16266 on turn 3), reported in the A2A usage metadata. Table in feat(models): add prompt caching for the Anthropic provider (backport to release/v0.10.x) #2789.

Follow-ups, not in this PR: the UI model form exposes neither this nor Bedrock's promptCaching; a docs note for kagent-dev/website supported-providers/anthropic.md once this lands.

Add `promptCaching` (default false) and `cacheTTL` ("5m" | "1h") to
`spec.anthropic` on ModelConfig, mirroring the knobs BedrockConfig got in
kagent-dev#1940, and honor them in both runtimes.

When enabled, every Messages API request carries three `cache_control`
breakpoints: the last tool definition, the last system prompt block and
the last content block of the latest conversation turn. Anthropic
renders tools, then system, then messages and caches the prefix up to
each breakpoint, so an agent loop reads its whole previous history from
the cache and writes only the new turn instead of paying full input
price for the same prefix on every call. "1h" opts into the 1-hour
cache; "5m" (the CRD default) leaves the API default in place.

Go runtime: buildAnthropicParams now owns the request shaping so it can
be unit-tested, markAnthropicCacheBreakpoints sets the markers, and
usage folds cache_read/cache_creation into PromptTokenCount with
cache_read surfaced as CachedContentTokenCount so the UI shows the
effect. Python runtime: KAgentAnthropicLlm wraps the SDK client's
messages resource and marks the same three breakpoints before each
request; google-adk already maps the cache usage fields.

The Claude harness compiler treats the defaulted cacheTTL "5m" like the
Bedrock one and keeps rejecting promptCaching, since Claude Code caches
natively.

Refs kagent-dev#2787, kagent-dev#1866.

Signed-off-by: Timo Derstappen <teemow@gmail.com>
…ests

Shallow copies would let a nested in-place mutation of a message or tool
block go unnoticed, which is exactly what these tests guard against.

Signed-off-by: Timo Derstappen <teemow@gmail.com>

@EItanya EItanya left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI-generated review.

No correctness issues found at f5679c2a. One optional simplification:

  1. Reuse Python ADK’s existing caching support_anthropic.py:81. The locked google-adk 2.8.0 already generates these three breakpoints through LlmRequest.cache_config. Mapping the settings to ContextCacheConfig could remove the custom messages wrapper and marker logic. I verified identical wire requests for both TTLs, with streaming enabled and disabled. Check support at the declared minimum ADK version before switching.

Validation passed: four affected Go packages (adk/pkg/models, adk/pkg/agent, core/internal/translator/adkconfig, and core/internal/translator/claude), all 24 Python Anthropic tests, and an additional SDK comparison probe using mocked HTTP. No live Anthropic or cluster tests run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Prompt caching for the Anthropic provider (cache_control on system prompt and tools), like Bedrock's promptCaching

2 participants