mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 15:06:04 +08:00
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:
@@ -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"])
|
||||
|
||||
# 添加到最终结果
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user