fix(provider): keep Anthropic tool calls whose streamed input is empty (#10432)

* fix(provider): keep Anthropic tool calls whose streamed input is empty

* fix(provider): keep the input from the Anthropic tool_use start event

The tool_use buffer always started from an empty dict, so a provider
that puts the arguments in the content_block_start event instead of
streaming them lost them. Start from that input and let streamed JSON
replace it only when there is any.
This commit is contained in:
Niansia
2026-10-08 22:01:50 +09:00
committed by GitHub
parent 9248baf902
commit 30820a48fa
2 changed files with 129 additions and 2 deletions
@@ -665,10 +665,15 @@ class ProviderAnthropic(Provider):
)
elif event.content_block.type == "tool_use":
# 工具使用块开始,初始化缓冲区
# Keep the input from the start event; streamed deltas
# replace it only when they carry JSON.
start_input = event.content_block.input
tool_use_buffer[event.index] = {
"id": event.content_block.id,
"name": event.content_block.name,
"input": {},
"input": start_input
if isinstance(start_input, dict)
else {},
}
elif event.type == "content_block_delta":
@@ -713,7 +718,8 @@ class ProviderAnthropic(Provider):
# 解析完整的工具调用
tool_info = tool_use_buffer[event.index]
try:
if "input_json" in tool_info:
# 无参数的工具只会收到一个空的 partial_json
if tool_info.get("input_json"):
tool_info["input"] = json.loads(tool_info["input_json"])
# 添加到最终结果
+121
View File
@@ -1,8 +1,18 @@
import json
import pytest
from anthropic import _base_client as anthropic_base_client
from anthropic.types import MessageDeltaUsage, Usage
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.provider.entities import TokenUsage
from astrbot.core.provider.sources.anthropic_source import ProviderAnthropic
# The SDK bundles its own httpx (``httpx`` before 1.0.0, ``httpx2`` after).
sdk_httpx = getattr(
anthropic_base_client, "httpx", getattr(anthropic_base_client, "httpx2", None)
)
def _provider() -> ProviderAnthropic:
return ProviderAnthropic.__new__(ProviderAnthropic)
@@ -75,3 +85,114 @@ def test_anthropic_update_usage_omitted_fields_are_preserved():
assert token_usage.input_other == 5
assert token_usage.input_cached == 0
assert token_usage.output == 7
def _tool_use_stream(
partial_json_chunks: list[str], start_input: dict | None = None
) -> bytes:
events = [
{
"type": "message_start",
"message": {
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude-test",
"content": [],
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": 10, "output_tokens": 1},
},
},
{
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "tool_use",
"id": "toolu_01",
"name": "get_time",
"input": start_input or {},
},
},
*(
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "input_json_delta", "partial_json": chunk},
}
for chunk in partial_json_chunks
),
{"type": "content_block_stop", "index": 0},
{
"type": "message_delta",
"delta": {"stop_reason": "tool_use", "stop_sequence": None},
"usage": {"output_tokens": 5},
},
{"type": "message_stop"},
]
return "".join(
f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in events
).encode()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("chunks", "start_input", "expected_args"),
[
([""], None, {}),
([], None, {}),
(["", '{"tz": "UTC"}'], None, {"tz": "UTC"}),
([], {"tz": "UTC"}, {"tz": "UTC"}),
],
)
async def test_anthropic_stream_keeps_tool_call_with_empty_input(
monkeypatch, chunks, start_input, expected_args
):
body = _tool_use_stream(chunks, start_input)
transport = sdk_httpx.MockTransport(
lambda request: sdk_httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=body
)
)
monkeypatch.setattr(
ProviderAnthropic,
"_create_http_client",
lambda self, provider_config: sdk_httpx.AsyncClient(transport=transport),
)
provider = ProviderAnthropic(
{
"id": "test",
"type": "anthropic_chat_completion",
"key": ["sk-test"],
"model": "claude-test",
"api_base": "https://api.anthropic.test",
},
{},
)
tools = ToolSet(
[
FunctionTool(
name="get_time",
description="Return the current time.",
parameters={"type": "object", "properties": {}},
)
]
)
payloads = {
"messages": [{"role": "user", "content": "what time is it?"}],
"model": "claude-test",
}
try:
responses = [r async for r in provider._query_stream(payloads, tools)]
finally:
await provider.terminate()
tool_chunks = [r for r in responses if r.role == "tool"]
assert len(tool_chunks) == 1
final = responses[-1]
assert not final.is_chunk
for response in (tool_chunks[0], final):
assert response.tools_call_name == ["get_time"]
assert response.tools_call_args == [expected_args]
assert response.tools_call_ids == ["toolu_01"]