fix(models): exclude thinking blocks from Claude cache breakpoints (#6339)

* fix(models): exclude thinking blocks from Claude cache breakpoints

* fix(models): declare Claude test SDK and clarify caching docs

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Guo-Yixin 2026-10-05 20:05:55 -05:00 committed by GitHub
parent d8bdd7552c
commit 52edef4f12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 127 additions and 2 deletions

View File

@ -289,6 +289,11 @@ For Google's official Gemini OpenAI-compatible endpoint, use the
For vLLM 0.19.0, use `deerflow.models.vllm_provider:VllmChatModel`. For Qwen-style reasoning models, DeerFlow toggles reasoning with `extra_body.chat_template_kwargs.enable_thinking` and preserves vLLM's non-standard `reasoning` field across multi-turn tool-call conversations. Legacy `thinking` configs are normalized automatically for backward compatibility. If the endpoint reports a cumulative usage snapshot on every streaming chunk, set `cumulative_stream_usage: true` so DeerFlow converts those snapshots into per-chunk deltas; the option is disabled by default and leaves usage unchanged when a stable completion id is unavailable. Reasoning models may also require the server to be started with `--reasoning-parser ...`. If your local vLLM deployment accepts any non-empty API key, you can still set `VLLM_API_KEY` to a placeholder value.
When prompt caching is enabled for a model configured with
`deerflow.models.claude_provider:ClaudeChatModel`, DeerFlow preserves thinking
and redacted-thinking history without placing cache breakpoints directly on
those blocks. Extended-thinking tool follow-ups can keep using prompt caching.
CLI-backed provider examples:
```yaml

View File

@ -103,6 +103,7 @@ Offline HTTP-stream coverage: `tests/test_codex_stream_terminal_events.py`.
- The request payload shares objects with the caller: langchain-anthropic forwards Claude-native blocks (an image or document with a `source`, search results) and list-form system blocks by reference, and a reused tool binding passes its own tool dicts (the lead agent re-binds per call, so its tool dicts are fresh). Writing `cache_control` in place checkpointed the markers with the thread's messages, and the stale ones pushed every later request past the 4-breakpoint limit
- Every request goes through `_strip_cache_control`, with caching on or off, so markers stored by older checkpoints never reach the API. It copies a marked block without its marker and replaces the system, message, content and tool lists and every message dict with copies; langchain-anthropic already builds fresh message dicts and content lists, so that part is defensive
- `_apply_prompt_caching` must call `_strip_cache_control` first: it then replaces slots in those payload-owned lists with marked copies and writes `msg["content"]` on copied message dicts, placing at most four breakpoints. Pinned by `tests/test_claude_provider_prompt_caching.py`
- Exclude `thinking` and `redacted_thinking` blocks before selecting the last four cache candidates: Anthropic forbids direct `cache_control` on these blocks. Keep their content, signatures/data and order intact so they remain part of the prefix covered by a later eligible breakpoint. Stripping stale markers must not add them back to thinking blocks or mutate caller-owned history. The same suite exercises real sync/async SDK tool-followup requests through offline transports.
### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`)

View File

@ -202,6 +202,9 @@ class ClaudeChatModel(ChatAnthropic):
placed on the *last* eligible blocks because later breakpoints cover a
larger prefix and yield better cache hit rates.
Thinking and redacted-thinking blocks cannot carry cache_control.
Preserve them in the cached prefix, but exclude them as breakpoints.
The budget covers the whole request, so markers already present are
dropped first. Checkpoints written before breakpoints were placed on
copies can still carry markers on old blocks, and those would otherwise
@ -245,7 +248,7 @@ class ClaudeChatModel(ChatAnthropic):
content = msg.get("content")
if isinstance(content, list):
for index, block in enumerate(content):
if isinstance(block, dict):
if isinstance(block, dict) and block.get("type") not in ("thinking", "redacted_thinking"):
candidates.append((content, index))
elif isinstance(content, str) and content:
msg["content"] = [{"type": "text", "text": content}]

View File

@ -58,6 +58,8 @@ memory-zh = ["deerflow-harness[memory-zh]"]
# the same locked runtime set without promoting those packages to core deps.
extensions = []
dev = [
# Direct SDK use in Claude provider serialization tests.
"anthropic>=0.97.0",
"blockbuster>=1.5.26,<1.6",
"hypothesis>=6.100,<7",
"jsonschema>=4.26.0",

View File

@ -18,6 +18,13 @@ The local sandbox's UTF-8 subprocess guard inspects each text-mode call with
`ast`, checking both `encoding` and `errors`; module-wide literal counts can
hide unpinned calls behind unrelated settings.
## Claude provider tests
`test_claude_provider_prompt_caching.py` exercises real Anthropic SDK serialization
through offline HTTP transports. Keep its directly imported `anthropic` SDK in
the backend `dev` dependency group rather than relying on `langchain-anthropic`
to supply it transitively.
## Router auth fixtures
For owner-scoped route assertions, pass a stable `user_factory` and

View File

@ -6,10 +6,15 @@ how many system blocks, message content blocks, or tool definitions are present,
and that it never writes into blocks the caller still owns.
"""
import asyncio
import copy
import json
from unittest import mock
import anthropic
import httpx
import pytest
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from deerflow.models.claude_provider import ClaudeChatModel
@ -345,3 +350,103 @@ def test_strip_cache_control_copies_instead_of_popping():
assert _count_cache_control(payload) == 0
assert payload["messages"][0] == {"role": "user", "content": [_native_image()]}
assert all("cache_control" in item for item in (system_block, image, tool, message))
def _thinking_block(block_type: str) -> dict:
if block_type == "thinking":
return {"type": "thinking", "thinking": "Check the report before answering.", "signature": "test-signature"}
return {"type": "redacted_thinking", "data": "test-redacted-data"}
@pytest.mark.parametrize("block_type", ["thinking", "redacted_thinking"])
@pytest.mark.parametrize("thinking_last", [False, True])
@pytest.mark.parametrize("stale_marker", [False, True])
def test_thinking_blocks_do_not_consume_cache_breakpoints(model, block_type, thinking_last, stale_marker):
thinking = _thinking_block(block_type)
if stale_marker:
thinking["cache_control"] = {"type": "ephemeral"}
text = {"type": "text", "text": "Report ready."}
payload = {
"system": [{"type": "text", "text": "System one"}, {"type": "text", "text": "System two"}],
"messages": [
{"role": "user", "content": [{"type": "text", "text": "Prepare the report."}]},
{"role": "assistant", "content": [text, thinking] if thinking_last else [thinking, text]},
{"role": "user", "content": [{"type": "text", "text": "Save it."}]},
],
"tools": [{"name": "save_report"}],
}
original = copy.deepcopy(payload)
model._apply_prompt_caching(payload)
assistant = payload["messages"][1]["content"]
assert next(block for block in assistant if block["type"] == block_type) == _thinking_block(block_type)
assert _count_cache_control(payload) == 4
assert all("cache_control" not in block for block in payload["system"])
assert payload["messages"][0]["content"][0]["cache_control"] == {"type": "ephemeral"}
assert next(block for block in assistant if block["type"] == "text")["cache_control"] == {"type": "ephemeral"}
assert payload["messages"][2]["content"][0]["cache_control"] == {"type": "ephemeral"}
assert payload["tools"][0]["cache_control"] == {"type": "ephemeral"}
assert original["messages"][1]["content"][1 if thinking_last else 0] == thinking
assert original["messages"][1]["content"][0 if thinking_last else 1] == text
@pytest.mark.parametrize("block_type", ["thinking", "redacted_thinking"])
@pytest.mark.parametrize("invocation", ["invoke", "ainvoke"])
@pytest.mark.parametrize("enable_prompt_caching", [True, False])
def test_thinking_tool_followup_uses_valid_cache_breakpoints(block_type, invocation, enable_prompt_caching):
history = [
SystemMessage(content="Check report status."),
HumanMessage(content="Is report.md ready?"),
AIMessage(content=[_thinking_block(block_type), {"type": "tool_use", "id": "call-report", "name": "read_report", "input": {"name": "report.md"}}]),
ToolMessage(content="Report ready.", tool_call_id="call-report"),
]
original = [message.model_dump() for message in history]
requests = []
def handle_request(request):
payload = json.loads(request.content)
requests.append(payload)
blocks = [block for message in payload["messages"] if isinstance(message["content"], list) for block in message["content"]]
if any(block["type"] in ("thinking", "redacted_thinking") and "cache_control" in block for block in blocks):
return httpx.Response(400, json={"type": "error", "error": {"type": "invalid_request_error", "message": "Thinking blocks cannot have cache_control."}})
return httpx.Response(
200,
json={
"id": "msg-report",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-6",
"content": [{"type": "text", "text": "Report ready."}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 20, "output_tokens": 3},
},
)
m = ClaudeChatModel(model="claude-sonnet-4-6", anthropic_api_key="sk-ant-fake", max_tokens=4096, thinking={"type": "enabled", "budget_tokens": 1024}, enable_prompt_caching=enable_prompt_caching, max_retries=0, retry_max_attempts=1)
caller = m.bind_tools([{"name": "read_report", "description": "Read report status.", "input_schema": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}])
transport = httpx.MockTransport(handle_request)
if invocation == "invoke":
with anthropic.Anthropic(api_key="sk-ant-fake", max_retries=0, http_client=httpx.Client(transport=transport)) as client:
m._client = client
result = caller.invoke(history)
else:
async def run():
async with anthropic.AsyncAnthropic(api_key="sk-ant-fake", max_retries=0, http_client=httpx.AsyncClient(transport=transport)) as client:
m._async_client = client
return await caller.ainvoke(history)
result = asyncio.run(run())
assert result.content == "Report ready."
assert result.usage_metadata == {"input_tokens": 20, "output_tokens": 3, "total_tokens": 23, "input_token_details": {}}
assert len(requests) == 1
payload = requests[0]
assert _count_cache_control(payload) == (4 if enable_prompt_caching else 0)
assistant = next(message for message in payload["messages"] if message["role"] == "assistant")
assert assistant["content"][0] == _thinking_block(block_type)
assert assistant["content"][1]["type"] == "tool_use"
assert payload["messages"][-1]["content"][0]["tool_use_id"] == "call-report"
assert [message.model_dump() for message in history] == original

2
backend/uv.lock generated
View File

@ -856,6 +856,7 @@ redis = [
[package.dev-dependencies]
dev = [
{ name = "anthropic" },
{ name = "blockbuster" },
{ name = "hypothesis" },
{ name = "jsonschema" },
@ -904,6 +905,7 @@ provides-extras = ["postgres", "redis", "discord", "buzz", "monocle", "ollama",
[package.metadata.requires-dev]
dev = [
{ name = "anthropic", specifier = ">=0.97.0" },
{ name = "blockbuster", specifier = ">=1.5.26,<1.6" },
{ name = "hypothesis", specifier = ">=6.100,<7" },
{ name = "jsonschema", specifier = ">=4.26.0" },