diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 7e2e09f91..cd6262faa 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -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"]) # 添加到最终结果 diff --git a/tests/test_anthropic_source.py b/tests/test_anthropic_source.py index 4aa0f2efb..2b283be08 100644 --- a/tests/test_anthropic_source.py +++ b/tests/test_anthropic_source.py @@ -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"]