Skip to content

Commit 7863997

Browse files
xfgongclaude
andauthored
feat: implement Anthropic prompt caching with cache_control markers (#10)
* feat: implement Anthropic prompt caching with cache_control markers Add active prompt caching to AnthropicProvider by sending cache_control markers to the Anthropic API, matching the pi-agent-core TS reference implementation. Previously, the provider only passively tracked cache token counts without requesting caching. Changes: - Add cache_control: {"type": "ephemeral"} on system prompt content blocks - Add cache_control on the last tool definition (when tools are provided) - Add cache_control on the last message content block (conversation prefix) - Add CacheRetention type ("short" | "long" | "none") and cache_retention parameter to AnthropicProvider (default: "short") - System prompt now sent as structured content block array (required for cache_control attachment) - Add 15 tests covering all caching behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add 1h TTL for long cache retention to match Anthropic API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dd8d056 commit 7863997

2 files changed

Lines changed: 239 additions & 6 deletions

File tree

cubepi/providers/anthropic.py

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import asyncio
44
import time
5-
from typing import Any
5+
from typing import Any, Literal
66

77
from cubepi.providers.base import (
88
AssistantMessage,
@@ -23,12 +23,20 @@
2323
adjust_max_tokens_for_thinking,
2424
)
2525

26+
CacheRetention = Literal["short", "long", "none"]
27+
2628

2729
class AnthropicProvider:
28-
def __init__(self, *, api_key: str | None = None) -> None:
30+
def __init__(
31+
self,
32+
*,
33+
api_key: str | None = None,
34+
cache_retention: CacheRetention = "short",
35+
) -> None:
2936
import anthropic
3037

3138
self._client = anthropic.AsyncAnthropic(api_key=api_key)
39+
self._cache_retention = cache_retention
3240

3341
async def stream(
3442
self,
@@ -43,9 +51,11 @@ async def stream(
4351
) -> MessageStream:
4452
ms = MessageStream()
4553

54+
cache_control = self._get_cache_control()
4655
api_messages = [self._convert_message(m) for m in messages]
56+
if cache_control:
57+
self._apply_message_cache_control(api_messages, cache_control)
4758

48-
# Adjust max_tokens to accommodate the thinking budget
4959
max_tokens, thinking_budget = adjust_max_tokens_for_thinking(
5060
base_max_tokens=model.max_tokens,
5161
model_max_tokens=model.context_window,
@@ -59,9 +69,18 @@ async def stream(
5969
"max_tokens": max_tokens,
6070
}
6171
if system_prompt:
62-
kwargs["system"] = system_prompt
72+
kwargs["system"] = [
73+
{
74+
"type": "text",
75+
"text": system_prompt,
76+
**({"cache_control": cache_control} if cache_control else {}),
77+
}
78+
]
6379
if tools:
64-
kwargs["tools"] = [self._convert_tool(t) for t in tools]
80+
api_tools = [self._convert_tool(t) for t in tools]
81+
if cache_control and api_tools:
82+
api_tools[-1]["cache_control"] = cache_control
83+
kwargs["tools"] = api_tools
6584
if thinking != "off" and thinking_budget > 0:
6685
kwargs["thinking"] = {
6786
"type": "enabled",
@@ -118,6 +137,42 @@ async def _produce() -> None:
118137
asyncio.create_task(_produce())
119138
return ms
120139

140+
def _get_cache_control(self) -> dict[str, str] | None:
141+
if self._cache_retention == "none":
142+
return None
143+
cc: dict[str, str] = {"type": "ephemeral"}
144+
if self._cache_retention == "long":
145+
cc["ttl"] = "1h"
146+
return cc
147+
148+
@staticmethod
149+
def _apply_message_cache_control(
150+
api_messages: list[dict[str, Any]],
151+
cache_control: dict[str, str],
152+
) -> None:
153+
"""Apply cache_control to the last content block of the last message.
154+
155+
This caches the conversation prefix so subsequent turns get cache hits
156+
on all prior messages.
157+
"""
158+
if not api_messages:
159+
return
160+
161+
last_msg = api_messages[-1]
162+
content = last_msg.get("content")
163+
if not content:
164+
return
165+
166+
if isinstance(content, list) and len(content) > 0:
167+
last_block = content[-1]
168+
if isinstance(last_block, dict):
169+
last_block["cache_control"] = cache_control
170+
elif isinstance(content, str):
171+
# Convert bare string content to a block so we can attach cache_control
172+
last_msg["content"] = [
173+
{"type": "text", "text": content, "cache_control": cache_control}
174+
]
175+
121176
@staticmethod
122177
def _convert_message(msg: Message) -> dict[str, Any]:
123178
if isinstance(msg, UserMessage):

tests/providers/test_anthropic.py

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from cubepi.providers.anthropic import AnthropicProvider
1+
from cubepi.providers.anthropic import AnthropicProvider, CacheRetention
22
from cubepi.providers.base import (
33
TextContent,
44
ToolCall,
@@ -60,3 +60,181 @@ def test_convert_tool_definition(self):
6060
assert result["name"] == "search"
6161
assert result["description"] == "Search the web"
6262
assert result["input_schema"]["type"] == "object"
63+
64+
65+
# ---------------------------------------------------------------------------
66+
# Prompt caching tests
67+
# ---------------------------------------------------------------------------
68+
69+
70+
def _make_provider(retention: CacheRetention = "short") -> AnthropicProvider:
71+
"""Create a provider without hitting the network (api_key is unused in tests)."""
72+
return AnthropicProvider(api_key="test-key", cache_retention=retention)
73+
74+
75+
class TestCacheRetention:
76+
def test_default_retention_is_short(self):
77+
provider = AnthropicProvider(api_key="test-key")
78+
assert provider._cache_retention == "short"
79+
80+
def test_retention_none_returns_no_cache_control(self):
81+
provider = _make_provider("none")
82+
assert provider._get_cache_control() is None
83+
84+
def test_retention_short_returns_ephemeral(self):
85+
provider = _make_provider("short")
86+
cc = provider._get_cache_control()
87+
assert cc == {"type": "ephemeral"}
88+
89+
def test_retention_long_returns_ephemeral_with_ttl(self):
90+
provider = _make_provider("long")
91+
cc = provider._get_cache_control()
92+
assert cc == {"type": "ephemeral", "ttl": "1h"}
93+
94+
95+
class TestCacheControlOnMessages:
96+
"""Verify cache_control markers are placed on the last message content block."""
97+
98+
CACHE_CONTROL = {"type": "ephemeral"}
99+
100+
def test_cache_control_on_last_user_message_text(self):
101+
msgs = [
102+
{"role": "user", "content": [{"type": "text", "text": "first"}]},
103+
{"role": "assistant", "content": [{"type": "text", "text": "reply"}]},
104+
{"role": "user", "content": [{"type": "text", "text": "second"}]},
105+
]
106+
AnthropicProvider._apply_message_cache_control(msgs, self.CACHE_CONTROL)
107+
108+
# Only the last message's last block should have cache_control
109+
assert msgs[-1]["content"][-1]["cache_control"] == self.CACHE_CONTROL
110+
assert "cache_control" not in msgs[0]["content"][0]
111+
assert "cache_control" not in msgs[1]["content"][0]
112+
113+
def test_cache_control_on_last_tool_result(self):
114+
msgs = [
115+
{
116+
"role": "user",
117+
"content": [
118+
{
119+
"type": "tool_result",
120+
"tool_use_id": "tc-1",
121+
"content": [{"type": "text", "text": "ok"}],
122+
"is_error": False,
123+
}
124+
],
125+
}
126+
]
127+
AnthropicProvider._apply_message_cache_control(msgs, self.CACHE_CONTROL)
128+
assert msgs[0]["content"][-1]["cache_control"] == self.CACHE_CONTROL
129+
130+
def test_cache_control_on_multi_block_message(self):
131+
msgs = [
132+
{
133+
"role": "user",
134+
"content": [
135+
{"type": "text", "text": "part one"},
136+
{"type": "text", "text": "part two"},
137+
],
138+
}
139+
]
140+
AnthropicProvider._apply_message_cache_control(msgs, self.CACHE_CONTROL)
141+
# Only the last block gets the marker
142+
assert "cache_control" not in msgs[0]["content"][0]
143+
assert msgs[0]["content"][1]["cache_control"] == self.CACHE_CONTROL
144+
145+
def test_cache_control_converts_bare_string_content(self):
146+
msgs = [{"role": "user", "content": "bare string"}]
147+
AnthropicProvider._apply_message_cache_control(msgs, self.CACHE_CONTROL)
148+
# Should have been converted to a list with a text block
149+
assert isinstance(msgs[0]["content"], list)
150+
assert msgs[0]["content"][0]["type"] == "text"
151+
assert msgs[0]["content"][0]["text"] == "bare string"
152+
assert msgs[0]["content"][0]["cache_control"] == self.CACHE_CONTROL
153+
154+
def test_empty_messages_is_noop(self):
155+
msgs: list[dict] = []
156+
# Should not raise
157+
AnthropicProvider._apply_message_cache_control(msgs, self.CACHE_CONTROL)
158+
assert msgs == []
159+
160+
def test_no_cache_control_when_retention_none(self):
161+
provider = _make_provider("none")
162+
assert provider._get_cache_control() is None
163+
164+
165+
class TestCacheControlOnSystemPrompt:
166+
"""Verify that the stream method builds system prompt blocks with cache_control."""
167+
168+
def test_system_prompt_has_cache_control(self):
169+
"""The system prompt should be sent as a content block with cache_control."""
170+
provider = _make_provider("short")
171+
cache_control = provider._get_cache_control()
172+
# Simulate what stream() does for system_prompt
173+
system_prompt = "You are a helpful assistant."
174+
system_block = {
175+
"type": "text",
176+
"text": system_prompt,
177+
**({"cache_control": cache_control} if cache_control else {}),
178+
}
179+
assert system_block["cache_control"] == {"type": "ephemeral"}
180+
181+
def test_system_prompt_no_cache_when_retention_none(self):
182+
provider = _make_provider("none")
183+
cache_control = provider._get_cache_control()
184+
system_prompt = "You are a helpful assistant."
185+
system_block = {
186+
"type": "text",
187+
"text": system_prompt,
188+
**({"cache_control": cache_control} if cache_control else {}),
189+
}
190+
assert "cache_control" not in system_block
191+
192+
193+
class TestCacheControlOnTools:
194+
"""Verify cache_control is applied to the last tool definition."""
195+
196+
CACHE_CONTROL = {"type": "ephemeral"}
197+
198+
def _make_tools(self, count: int) -> list[ToolDefinition]:
199+
return [
200+
ToolDefinition(
201+
name=f"tool_{i}",
202+
description=f"Tool {i}",
203+
parameters={
204+
"type": "object",
205+
"properties": {"x": {"type": "string"}},
206+
},
207+
)
208+
for i in range(count)
209+
]
210+
211+
def test_cache_control_on_last_tool_only(self):
212+
tools = self._make_tools(3)
213+
api_tools = [AnthropicProvider._convert_tool(t) for t in tools]
214+
# Apply cache_control the same way stream() does
215+
if api_tools:
216+
api_tools[-1]["cache_control"] = self.CACHE_CONTROL
217+
218+
assert "cache_control" not in api_tools[0]
219+
assert "cache_control" not in api_tools[1]
220+
assert api_tools[2]["cache_control"] == self.CACHE_CONTROL
221+
222+
def test_single_tool_gets_cache_control(self):
223+
tools = self._make_tools(1)
224+
api_tools = [AnthropicProvider._convert_tool(t) for t in tools]
225+
if api_tools:
226+
api_tools[-1]["cache_control"] = self.CACHE_CONTROL
227+
228+
assert api_tools[0]["cache_control"] == self.CACHE_CONTROL
229+
230+
def test_no_cache_control_when_retention_none(self):
231+
provider = _make_provider("none")
232+
cache_control = provider._get_cache_control()
233+
tools = self._make_tools(2)
234+
api_tools = [AnthropicProvider._convert_tool(t) for t in tools]
235+
if cache_control and api_tools:
236+
api_tools[-1]["cache_control"] = cache_control
237+
238+
# With retention="none", no tool should have cache_control
239+
for tool in api_tools:
240+
assert "cache_control" not in tool

0 commit comments

Comments
 (0)