Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 35 additions & 16 deletions astrbot/core/provider/sources/anthropic_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,11 +322,21 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
if tools:
if tool_list := tools.get_func_desc_anthropic_style():
payloads["tools"] = tool_list
payloads["tool_choice"] = {
"type": "any"
if payloads.get("tool_choice") == "required"
else "auto"
}
# 转换为 Anthropic API 要求的 tool_choice 格式
# 参考: https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools#providing-tool-use-examples
if "tool_choice" in payloads:
tool_choice = payloads["tool_choice"]
if isinstance(tool_choice, dict):
payloads["tool_choice"] = tool_choice
elif tool_choice == "required":
# 兼容 OpenAI 命名
payloads["tool_choice"] = {"type": "any"}
elif tool_choice in ("auto", "any", "none", "tool"):
Comment on lines +325 to +334
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The tool_choice normalization logic is duplicated between _query and _query_stream and could be extracted to a shared helper.

A helper like _normalize_tool_choice(payloads) would centralize this Anthropic-specific mapping and reduce the risk that sync and streaming diverge if the API or supported values change.

payloads["tool_choice"] = {"type": tool_choice}
Comment on lines +334 to +335
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Using {"type": "tool"} without a name is likely invalid per Anthropic’s tool_choice schema.

Per Anthropic’s schema, {"type": "tool"} generally must include a "name" (and possibly other fields) to indicate which tool to force. Mapping the bare string "tool" to {"type": "tool"} like the other simple variants may generate an invalid request. You could either require a dict for the "tool" case (e.g., {"type": "tool", "name": ...}) or remove "tool" from this simple string set and handle it separately so you can enforce presence of a tool name.

Comment on lines +334 to +335
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

这里的逻辑存在两个问题:

  1. tool 类型缺失参数:根据 Anthropic 文档,当 type"tool" 时,必须提供 name 字段指定具体的工具名称。仅发送 {"type": "tool"} 会导致 API 报错。建议在需要指定特定工具时,要求调用者传入 dict 格式(如 {"type": "tool", "name": "my_tool"})。
  2. 不支持 none 类型:Anthropic API 的 tool_choice 并不支持 "none"。如果希望不使用工具,通常的做法是不在请求中包含 tools 字段,或者使用 "auto"。发送 {"type": "none"} 可能会导致 API 返回 400 错误。
Suggested change
elif tool_choice in ("auto", "any", "none", "tool"):
payloads["tool_choice"] = {"type": tool_choice}
elif tool_choice in ("auto", "any"):
payloads["tool_choice"] = {"type": tool_choice}

else:
payloads["tool_choice"] = {"type": "auto"}
else:
payloads["tool_choice"] = {"type": "auto"}
Comment on lines +325 to +339
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

这里的 tool_choice 转换逻辑在 _query_query_stream 中完全重复。根据通用规则,建议将其提取为一个私有辅助函数(例如 _format_anthropic_tool_choice),以提高代码的可维护性并减少冗余。

此外,根据项目规则,新功能(如工具选择处理)应伴随相应的单元测试。

References
  1. When implementing similar functionality for different cases (e.g., direct vs. quoted attachments), refactor the logic into a shared helper function to avoid code duplication.
  2. New functionality, such as handling attachments, should be accompanied by corresponding unit tests.


extra_body = self.provider_config.get("custom_extra_body", {})

Expand Down Expand Up @@ -409,11 +419,20 @@ async def _query_stream(
if tools:
if tool_list := tools.get_func_desc_anthropic_style():
payloads["tools"] = tool_list
payloads["tool_choice"] = {
"type": "any"
if payloads.get("tool_choice") == "required"
else "auto"
}
# 转换为 Anthropic API 要求的 tool_choice 格式
if "tool_choice" in payloads:
tool_choice = payloads["tool_choice"]
if isinstance(tool_choice, dict):
payloads["tool_choice"] = tool_choice
elif tool_choice == "required":
# 兼容 OpenAI 命名
payloads["tool_choice"] = {"type": "any"}
elif tool_choice in ("auto", "any", "none", "tool"):
payloads["tool_choice"] = {"type": tool_choice}
Comment on lines +430 to +431
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

_query 中的问题,此处也不应包含不支持的 "none" 类型,且 "tool" 类型需要额外的 name 参数,不应作为简单的字符串处理。

Suggested change
elif tool_choice in ("auto", "any", "none", "tool"):
payloads["tool_choice"] = {"type": tool_choice}
elif tool_choice in ("auto", "any"):
payloads["tool_choice"] = {"type": tool_choice}

else:
payloads["tool_choice"] = {"type": "auto"}
else:
payloads["tool_choice"] = {"type": "auto"}

# 用于累积工具调用信息
tool_use_buffer = {}
Expand Down Expand Up @@ -569,7 +588,7 @@ async def text_chat(
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
tool_choice: Literal["auto", "required"] = "auto",
tool_choice: Literal["auto", "any", "tool", "none"] | dict[str, str] = "auto",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

建议从 Literal 中移除 "tool""none"。如前所述,"none" 不是 Anthropic 支持的类型,而 "tool" 需要额外的 name 参数,应该通过 dict 类型传入以确保参数完整性。同时保留 "required" 以维持对 OpenAI 风格命名的兼容性。

Suggested change
tool_choice: Literal["auto", "any", "tool", "none"] | dict[str, str] = "auto",
tool_choice: Literal["auto", "any", "required"] | dict[str, str] = "auto",

**kwargs,
) -> LLMResponse:
if contexts is None:
Expand Down Expand Up @@ -598,8 +617,8 @@ async def text_chat(
if not isinstance(tool_calls_result, list):
context_query.extend(tool_calls_result.to_openai_messages())
else:
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
for tool_choicer in tool_calls_result:
context_query.extend(tool_choicer.to_openai_messages())
Comment on lines +620 to +621
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

变量名 tool_choicer 容易引起误解,因为它遍历的是 tool_calls_result(工具调用结果),而不是工具选择器。建议使用更准确的名称,如 tool_call_res

Suggested change
for tool_choicer in tool_calls_result:
context_query.extend(tool_choicer.to_openai_messages())
for tool_call_res in tool_calls_result:
context_query.extend(tool_call_res.to_openai_messages())


system_prompt, new_messages = self._prepare_payload(context_query)

Expand Down Expand Up @@ -637,7 +656,7 @@ async def text_chat_stream(
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
tool_choice: Literal["auto", "required"] = "auto",
tool_choice: Literal["auto", "any", "tool", "none"] | dict[str, str] = "auto",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

建议同步更新 text_chat_stream 中的 tool_choice 类型标注,移除不支持或不完整的字符串字面量。

Suggested change
tool_choice: Literal["auto", "any", "tool", "none"] | dict[str, str] = "auto",
tool_choice: Literal["auto", "any", "required"] | dict[str, str] = "auto",

**kwargs,
):
if contexts is None:
Expand Down Expand Up @@ -665,8 +684,8 @@ async def text_chat_stream(
if not isinstance(tool_calls_result, list):
context_query.extend(tool_calls_result.to_openai_messages())
else:
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
for tool_choicer in tool_calls_result:
context_query.extend(tool_choicer.to_openai_messages())
Comment on lines +687 to +688
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

变量名 tool_choicer 容易引起误解,建议使用更准确的名称,如 tool_call_res

Suggested change
for tool_choicer in tool_calls_result:
context_query.extend(tool_choicer.to_openai_messages())
for tool_call_res in tool_calls_result:
context_query.extend(tool_call_res.to_openai_messages())


system_prompt, new_messages = self._prepare_payload(context_query)

Expand Down
169 changes: 169 additions & 0 deletions tests/test_anthropic_kimi_code_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,172 @@ def test_prepare_payload_does_not_merge_non_consecutive_tool_results():
],
},
]


# ---- tool_choice 转换测试 ----


class _FakeToolSet:
"""模拟包含工具的 ToolSet"""

def get_func_desc_anthropic_style(self):
return [{"name": "get_weather", "description": "Get weather"}]

def empty(self):
Comment on lines +424 to +430
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding a test for the case where tools are present but get_func_desc_anthropic_style() returns an empty list to ensure tool_choice is not set in that scenario.

Right now _FakeToolSet always returns a non-empty list and empty() is always False, so the tests only cover the truthy branch of tool_list := tools.get_func_desc_anthropic_style(). Please add another fake (or parameterize this one) that returns [] (and possibly True from empty() if the provider uses it), and assert that in this case no tools or tool_choice keys are added to the payload. This will help prevent regressions where tool_choice is sent even when there are no tools.

Suggested implementation:

# ---- tool_choice 转换测试 ----




# ---- tool_choice 转换测试 ----


class _FakeToolSet:
    """模拟包含工具的 ToolSet"""

    def get_func_desc_anthropic_style(self):
        return [{"name": "get_weather", "description": "Get weather"}]

    def empty(self) -> bool:
        """指示该 ToolSet 非空"""
        return False


class _EmptyToolSet:
    """模拟包含空工具列表的 ToolSet,用于测试 tool_choice 在无工具时不应被设置"""

    def get_func_desc_anthropic_style(self):
        # 显式返回空列表,触发代码中对 falsy tool_list 分支的处理
        return []

    def empty(self) -> bool:
        """对于一些实现会检查 tools.empty(),这里应返回 True。"""
        return True

要完整实现你的建议,还需要在本文件中新增或扩展测试用例,大致步骤如下:

  1. 在现有使用 _FakeToolSet 的测试附近,新增一个使用 _EmptyToolSet 的测试(或将现有测试参数化),例如:

    • 构造 tools = _EmptyToolSet()
    • 通过当前被测的 provider/构造函数/辅助函数(例如构造请求 payload 的函数)生成 payload。
    • assert "tools" not in payloadassert not payload.get("tools")
    • assert "tool_choice" not in payload
  2. 如果生产代码在构造 payload 时会先检查 tools.empty() 再检查 tool_list := tools.get_func_desc_anthropic_style(),请确保测试同时覆盖两种分支:

    • 非空 _FakeToolSet:断言 tools 和(根据逻辑)tool_choice 被正确设置。
    • _EmptyToolSet:断言既没有 tools 也没有 tool_choice 字段。
  3. 由于当前片段中看不到具体的 provider 函数/类名,你需要在新增测试中调用实际用于构造 Anthropic/Kimi 请求 payload 的 API(例如 AnthropicKimiCodeProvider._build_payload(...) 或类似函数),并将上述断言应用到该函数的返回值上。

return False


class _FakeMessages:
"""模拟 AsyncAnthropic.messages 命名空间"""


async def _capture_payloads_create(**kwargs):
"""捕获 payloads 并返回一个真实的 Message 实例"""
from anthropic.types import Message, TextBlock, Usage

_capture_payloads_create.last_kwargs = kwargs
return Message(
id="msg_fake",
content=[TextBlock(type="text", text="Hello")],
model="claude-test",
role="assistant",
stop_reason=None,
stop_sequence=None,
type="message",
usage=Usage(input_tokens=10, output_tokens=5),
)


def _setup_provider_with_mock_client(monkeypatch) -> anthropic_source.ProviderAnthropic:
"""创建 provider 并 mock 底层 API 调用"""
monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic)

provider = anthropic_source.ProviderAnthropic(
provider_config={
"id": "anthropic-test",
"type": "anthropic_chat_completion",
"model": "claude-test",
"key": ["test-key"],
},
provider_settings={},
)

fakeMessages = _FakeMessages()
fakeMessages.create = _capture_payloads_create
provider.client.messages = fakeMessages

return provider


@pytest.mark.asyncio
async def test_tool_choice_auto_converts_to_dict(monkeypatch):
"""tool_choice='auto' 应转换为 {'type': 'auto'}"""
provider = _setup_provider_with_mock_client(monkeypatch)

await provider.text_chat(
prompt="hello",
func_tool=_FakeToolSet(),
tool_choice="auto",
)

assert _capture_payloads_create.last_kwargs["tool_choice"] == {"type": "auto"}


@pytest.mark.asyncio
async def test_tool_choice_any_converts_to_dict(monkeypatch):
"""tool_choice='any' 应转换为 {'type': 'any'}"""
provider = _setup_provider_with_mock_client(monkeypatch)

await provider.text_chat(
prompt="hello",
func_tool=_FakeToolSet(),
tool_choice="any",
)

assert _capture_payloads_create.last_kwargs["tool_choice"] == {"type": "any"}


@pytest.mark.asyncio
async def test_tool_choice_none_converts_to_dict(monkeypatch):
"""tool_choice='none' 应转换为 {'type': 'none'}"""
provider = _setup_provider_with_mock_client(monkeypatch)

await provider.text_chat(
prompt="hello",
func_tool=_FakeToolSet(),
tool_choice="none",
)

assert _capture_payloads_create.last_kwargs["tool_choice"] == {"type": "none"}


@pytest.mark.asyncio
async def test_tool_choice_required_legacy_compat(monkeypatch):
"""tool_choice='required'(OpenAI 命名) 应兼容转换为 {'type': 'any'}"""
provider = _setup_provider_with_mock_client(monkeypatch)

await provider.text_chat(
prompt="hello",
func_tool=_FakeToolSet(),
tool_choice="required",
)

assert _capture_payloads_create.last_kwargs["tool_choice"] == {"type": "any"}


@pytest.mark.asyncio
async def test_tool_choice_dict_passthrough(monkeypatch):
"""tool_choice 为 dict 时应直接透传"""
provider = _setup_provider_with_mock_client(monkeypatch)

await provider.text_chat(
prompt="hello",
func_tool=_FakeToolSet(),
tool_choice={"type": "tool", "name": "get_weather"},
)

assert _capture_payloads_create.last_kwargs["tool_choice"] == {
"type": "tool",
"name": "get_weather",
}


@pytest.mark.asyncio
async def test_tool_choice_default_when_not_set(monkeypatch):
"""未传 tool_choice 时,默认应为 {'type': 'auto'}"""
provider = _setup_provider_with_mock_client(monkeypatch)

await provider.text_chat(
prompt="hello",
func_tool=_FakeToolSet(),
)

assert _capture_payloads_create.last_kwargs["tool_choice"] == {"type": "auto"}


@pytest.mark.asyncio
async def test_tool_choice_invalid_string_falls_back_to_auto(monkeypatch):
"""无效的 tool_choice 字符串应回退为 {'type': 'auto'}"""
provider = _setup_provider_with_mock_client(monkeypatch)

await provider.text_chat(
prompt="hello",
func_tool=_FakeToolSet(),
tool_choice="invalid_value",
)

assert _capture_payloads_create.last_kwargs["tool_choice"] == {"type": "auto"}


@pytest.mark.asyncio
async def test_tool_choice_no_tools_skips_tool_choice(monkeypatch):
"""无工具时不应设置 tool_choice"""
provider = _setup_provider_with_mock_client(monkeypatch)

await provider.text_chat(
prompt="hello",
func_tool=None,
tool_choice="any",
)

assert "tool_choice" not in _capture_payloads_create.last_kwargs
Loading