Skip to content

fix(anthropic): 修复 Anthropic API tool_choice 格式转换及参数支持#8328

Open
NayukiChiba wants to merge 1 commit into
AstrBotDevs:masterfrom
NayukiChiba:fix/8319-anthropic-api-tool-choice
Open

fix(anthropic): 修复 Anthropic API tool_choice 格式转换及参数支持#8328
NayukiChiba wants to merge 1 commit into
AstrBotDevs:masterfrom
NayukiChiba:fix/8319-anthropic-api-tool-choice

Conversation

@NayukiChiba
Copy link
Copy Markdown
Contributor

@NayukiChiba NayukiChiba commented May 25, 2026

anthropic api的tool_choice类型错了,还跟openai的搞混了,把他们兼容一下,然后参考anthropic api官方文档修复tool_choice的内容

Modifications / 改动点

  • 将 tool_choice 从简单的 auto/required 逻辑改为遵循 Anthropic API 规范,支持 auto/any/none/tool 四种原生值
  • 兼容 OpenAI 风格的 tool_choice="required",自动映射为 {"type": "any"}
  • 允许直接传入 dict 类型的 tool_choice 以实现指定工具调用
  • 更新 text_chat 和 stream_chat 入口的参数类型标注,扩大可接收的 tool_choice 类型
  • 新增 tool_choice 格式转换的单元测试,覆盖各类输入场景

Closes #8319

anthropic_source.py中修复payload中的tool_choice字段,并且兼容原本的openai的required参数

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

test session starts
platform win32 -- Python 3.12.12, pytest-9.0.2, pluggy-1.6.0
rootdir: D:\Nayey\Code\NayukiChiba\AstrBot
configfile: pyproject.toml
plugins: anyio-4.12.1, asyncio-1.3.0, cov-7.0.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 20 items                                                                                     

tests\test_anthropic_kimi_code_provider.py [100%]

20 passed in 1.37s 


Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”

  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Align Anthropic provider tool_choice handling with Anthropic API semantics while maintaining backward compatibility with existing usage.

New Features:

  • Support Anthropic-native tool_choice values auto/any/none/tool and dict-based tool selection in text and streaming chat APIs.

Bug Fixes:

  • Correct Anthropic tool_choice payload formatting that previously mixed OpenAI and Anthropic conventions.

Enhancements:

  • Normalize and default tool_choice behavior in Anthropic requests, including graceful fallback for invalid values and omission when no tools are provided.
  • Relax tool_choice type annotations on Anthropic text and stream chat methods to accept the expanded set of options and dicts.

Tests:

  • Add comprehensive unit tests covering tool_choice conversion, defaulting, passthrough, and no-tools behavior for the Anthropic provider.

- 将 tool_choice 从简单的 auto/required 逻辑改为遵循 Anthropic API 规范,支持 auto/any/none/tool 四种原生值
- 兼容 OpenAI 风格的 tool_choice="required",自动映射为 {"type": "any"}
- 允许直接传入 dict 类型的 tool_choice 以实现指定工具调用
- 更新 text_chat 和 stream_chat 入口的参数类型标注,扩大可接收的 tool_choice 类型
- 新增 tool_choice 格式转换的单元测试,覆盖各类输入场景

Closes AstrBotDevs#8319
@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. labels May 25, 2026
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • The tool_choice handling logic in _query and _query_stream is duplicated; consider extracting it into a small helper (e.g. _normalize_tool_choice(payloads)) to keep behavior consistent and make future updates easier.
  • The tool_choice parameter type hints for text_chat/text_chat_stream no longer include the legacy 'required' value even though it is still supported at runtime; to avoid type-checker friction and better reflect behavior, consider adding 'required' back into the Literal or widening the type.
  • In the tool_calls_result iteration you renamed the loop variable to tool_choicer, which is misleading since it represents tool call results rather than tool choice; consider keeping a more accurate name like tcr or tool_call_result for readability.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `tool_choice` handling logic in `_query` and `_query_stream` is duplicated; consider extracting it into a small helper (e.g. `_normalize_tool_choice(payloads)`) to keep behavior consistent and make future updates easier.
- The `tool_choice` parameter type hints for `text_chat`/`text_chat_stream` no longer include the legacy `'required'` value even though it is still supported at runtime; to avoid type-checker friction and better reflect behavior, consider adding `'required'` back into the Literal or widening the type.
- In the `tool_calls_result` iteration you renamed the loop variable to `tool_choicer`, which is misleading since it represents tool call results rather than tool choice; consider keeping a more accurate name like `tcr` or `tool_call_result` for readability.

## Individual Comments

### Comment 1
<location path="astrbot/core/provider/sources/anthropic_source.py" line_range="325-334" />
<code_context>
-                    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}
+                    else:
+                        payloads["tool_choice"] = {"type": "auto"}
+                else:
+                    payloads["tool_choice"] = {"type": "auto"}
</code_context>
<issue_to_address>
**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.
</issue_to_address>

### Comment 2
<location path="astrbot/core/provider/sources/anthropic_source.py" line_range="334-335" />
<code_context>
+                    elif tool_choice == "required":
+                        # 兼容 OpenAI 命名
+                        payloads["tool_choice"] = {"type": "any"}
+                    elif tool_choice in ("auto", "any", "none", "tool"):
+                        payloads["tool_choice"] = {"type": tool_choice}
+                    else:
+                        payloads["tool_choice"] = {"type": "auto"}
</code_context>
<issue_to_address>
**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.
</issue_to_address>

### Comment 3
<location path="tests/test_anthropic_kimi_code_provider.py" line_range="424-430" />
<code_context>
+# ---- tool_choice 转换测试 ----
+
+
+class _FakeToolSet:
+    """模拟包含工具的 ToolSet"""
+
+    def get_func_desc_anthropic_style(self):
+        return [{"name": "get_weather", "description": "Get weather"}]
+
+    def empty(self):
+        return False
+
</code_context>
<issue_to_address>
**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:

```python
# ---- 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 payload``assert 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(...)` 或类似函数),并将上述断言应用到该函数的返回值上。
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +325 to +334
# 转换为 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"):
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.

Comment on lines +334 to +335
elif tool_choice in ("auto", "any", "none", "tool"):
payloads["tool_choice"] = {"type": tool_choice}
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 +424 to +430
class _FakeToolSet:
"""模拟包含工具的 ToolSet"""

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

def empty(self):
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(...) 或类似函数),并将上述断言应用到该函数的返回值上。

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request enhances the Anthropic provider by implementing tool_choice conversion logic to support various selection modes and maintain compatibility with OpenAI-style naming. It also includes a new test suite to verify these conversions. The reviewer identified critical issues where the tool type was missing the required name parameter and the unsupported none type was included. Feedback also suggested refactoring duplicated logic into a helper function, refining type annotations in method signatures, and renaming loop variables for better clarity.

Comment on lines +334 to +335
elif tool_choice in ("auto", "any", "none", "tool"):
payloads["tool_choice"] = {"type": tool_choice}
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}

Comment on lines +430 to +431
elif tool_choice in ("auto", "any", "none", "tool"):
payloads["tool_choice"] = {"type": tool_choice}
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}

Comment on lines +325 to +339
# 转换为 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"):
payloads["tool_choice"] = {"type": tool_choice}
else:
payloads["tool_choice"] = {"type": "auto"}
else:
payloads["tool_choice"] = {"type": "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

这里的 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.

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",

Comment on lines +620 to +621
for tool_choicer in tool_calls_result:
context_query.extend(tool_choicer.to_openai_messages())
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())

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",

Comment on lines +687 to +688
for tool_choicer in tool_calls_result:
context_query.extend(tool_choicer.to_openai_messages())
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())

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Anthropic provider tool_choice 格式不符合 API 规范

1 participant