mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
d8bdd7552c
commit
52edef4f12
@ -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
|
||||
|
||||
@ -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`)
|
||||
|
||||
|
||||
@ -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}]
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
2
backend/uv.lock
generated
@ -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" },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user