Skip to content

fix: dedupe duplicated tool call fields - #7765

Open
bugkeep wants to merge 3 commits into
AstrBotDevs:masterfrom
bugkeep:bugfix/7694-dedupe-toolcall
Open

bugkeep wants to merge 3 commits into
AstrBotDevs:masterfrom
bugkeep:bugfix/7694-dedupe-toolcall

Conversation

@bugkeep

@bugkeep bugkeep commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Fixes #7694.

Some OpenAI-compatible proxies can duplicate streaming chunks; when that happens, tool_call.id and tool_call.function.name can end up as a self-concatenated string (s + s). We now defensively de-duplicate those fields during completion parsing, and add a unit test covering the regression.

Summary by Sourcery

Handle duplicated tool call metadata from OpenAI-compatible proxies during completion parsing and add coverage for this regression.

Bug Fixes:

  • Normalize self-concatenated tool call IDs and function names when parsing OpenAI-style completions to avoid duplicated metadata from streaming proxies.

Tests:

  • Add an async unit test verifying that self-concatenated tool call IDs and names are de-duplicated when parsing OpenAI completions.

@dosubot dosubot Bot added size:S This PR changes 10-29 lines, ignoring generated files. area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. labels Apr 24, 2026

@sourcery-ai sourcery-ai Bot left a comment

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.

Hey - I've found 1 issue, and left some high level feedback:

  • Consider moving _dedupe_self_concatenated to a module-level helper (or shared utility) so it can be reused and more easily unit-tested in isolation rather than as an inner function.
  • The min_len thresholds (16 for IDs and 8 for names) are embedded as magic numbers; it would be clearer to lift these into named constants or document why these particular values are appropriate for the expected formats.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider moving `_dedupe_self_concatenated` to a module-level helper (or shared utility) so it can be reused and more easily unit-tested in isolation rather than as an inner function.
- The `min_len` thresholds (16 for IDs and 8 for names) are embedded as magic numbers; it would be clearer to lift these into named constants or document why these particular values are appropriate for the expected formats.

## Individual Comments

### Comment 1
<location path="tests/test_openai_source.py" line_range="1183-1185" />
<code_context>
+async def test_parse_openai_completion_dedupes_self_concatenated_tool_call_fields():
+    provider = _make_provider()
+    try:
+        tool_call_id = "call_95fae017db5b4a91b1259aba"
+        tool_name = "astr_kb_search"
+        completion = ChatCompletion.model_validate(
+            {
+                "id": "chatcmpl-toolcall-dup",
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test case where only one of `tool_call.id` or `tool_call.function.name` is duplicated to verify they are handled independently.

The current test only covers the case where both ID and function name are duplicated together. Please add two more cases: (a) duplicated ID with a normal function name, and (b) duplicated function name with a normal ID, to confirm each field’s deduping behavior is independent.

Suggested implementation:

```python
@pytest.mark.asyncio
async def test_parse_openai_completion_dedupes_self_concatenated_tool_call_id_only():
    provider = _make_provider()
    try:
        tool_call_id = "call_95fae017db5b4a91b1259aba"
        tool_name = "astr_kb_search"
        completion = ChatCompletion.model_validate(
            {
                "id": "chatcmpl-toolcall-dup-id-only",
                "object": "chat.completion",
                "created": 0,
                "model": "gpt-4o-mini",
                "choices": [
                    {
                        "index": 0,
                        "message": {
                            "role": "assistant",
                            "content": None,
                            "refusal": None,
                            "tool_calls": [
                                {
                                    "id": f"{tool_call_id}{tool_call_id}",
                                    "type": "function",
                                    "function": {
                                        "name": tool_name,
                                        "arguments": '{"query": "test"}',
                                    },
                                }
                            ],
                        },
                        "logprobs": None,
                        "finish_reason": "tool_calls",
                    }
                ],
                "usage": {
                    "prompt_tokens": 0,
                    "completion_tokens": 0,
                    "total_tokens": 0,
                },
            }
        )

        events = [e async for e in provider._parse_openai_completion(completion)]
        assert len(events) == 1

        output_message = events[0].output_message
        assert output_message is not None
        assert output_message.tool_calls is not None
        assert len(output_message.tool_calls) == 1

        tool_call = output_message.tool_calls[0]
        assert tool_call.id == tool_call_id
        assert tool_call.function.name == tool_name
    finally:
        await provider.terminate()


@pytest.mark.asyncio
async def test_parse_openai_completion_dedupes_self_concatenated_tool_call_function_name_only():
    provider = _make_provider()
    try:
        tool_call_id = "call_95fae017db5b4a91b1259aba"
        tool_name = "astr_kb_search"
        completion = ChatCompletion.model_validate(
            {
                "id": "chatcmpl-toolcall-dup-name-only",
                "object": "chat.completion",
                "created": 0,
                "model": "gpt-4o-mini",
                "choices": [
                    {
                        "index": 0,
                        "message": {
                            "role": "assistant",
                            "content": None,
                            "refusal": None,
                            "tool_calls": [
                                {
                                    "id": tool_call_id,
                                    "type": "function",
                                    "function": {
                                        "name": f"{tool_name}{tool_name}",
                                        "arguments": '{"query": "test"}',
                                    },
                                }
                            ],
                        },
                        "logprobs": None,
                        "finish_reason": "tool_calls",
                    }
                ],
                "usage": {
                    "prompt_tokens": 0,
                    "completion_tokens": 0,
                    "total_tokens": 0,
                },
            }
        )

        events = [e async for e in provider._parse_openai_completion(completion)]
        assert len(events) == 1

        output_message = events[0].output_message
        assert output_message is not None
        assert output_message.tool_calls is not None
        assert len(output_message.tool_calls) == 1

        tool_call = output_message.tool_calls[0]
        assert tool_call.id == tool_call_id
        assert tool_call.function.name == tool_name
    finally:
        await provider.terminate()


@pytest.mark.asyncio
async def test_parse_openai_completion_dedupes_self_concatenated_tool_call_fields():
    provider = _make_provider()
    try:
        tool_call_id = "call_95fae017db5b4a91b1259aba"
        tool_name = "astr_kb_search"
        completion = ChatCompletion.model_validate(
            {
                "id": "chatcmpl-toolcall-dup",
                "object": "chat.completion",
                "created": 0,
                "model": "gpt-4o-mini",
                "choices": [
                    {
                        "index": 0,
                        "message": {
                            "role": "assistant",
                            "content": None,
                            "refusal": None,

```

The new tests assume:
1. The helper under test is `provider._parse_openai_completion` and that it yields events with an `output_message.tool_calls` structure identical to the existing test.
2. The existing test constructs `tool_calls` as shown (a list with `id`, `type: "function"`, and `function: {name, arguments}`).

Please:
- Ensure the signature and usage of `provider._parse_openai_completion` and the event/`output_message` shape match those used in `test_parse_openai_completion_dedupes_self_concatenated_tool_call_fields`. If the existing test uses a different helper or field path, mirror that in the two new tests.
- Align the `tool_calls` payload shape (keys like `"tool_calls"`, `"type"`, `"function"`, `"arguments"`) with whatever is used in the existing dedupe test; update field names if they differ.
</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 thread tests/test_openai_source.py Outdated
Comment on lines +1183 to +1185
tool_call_id = "call_95fae017db5b4a91b1259aba"
tool_name = "astr_kb_search"
completion = ChatCompletion.model_validate(

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): Add a test case where only one of tool_call.id or tool_call.function.name is duplicated to verify they are handled independently.

The current test only covers the case where both ID and function name are duplicated together. Please add two more cases: (a) duplicated ID with a normal function name, and (b) duplicated function name with a normal ID, to confirm each field’s deduping behavior is independent.

Suggested implementation:

@pytest.mark.asyncio
async def test_parse_openai_completion_dedupes_self_concatenated_tool_call_id_only():
    provider = _make_provider()
    try:
        tool_call_id = "call_95fae017db5b4a91b1259aba"
        tool_name = "astr_kb_search"
        completion = ChatCompletion.model_validate(
            {
                "id": "chatcmpl-toolcall-dup-id-only",
                "object": "chat.completion",
                "created": 0,
                "model": "gpt-4o-mini",
                "choices": [
                    {
                        "index": 0,
                        "message": {
                            "role": "assistant",
                            "content": None,
                            "refusal": None,
                            "tool_calls": [
                                {
                                    "id": f"{tool_call_id}{tool_call_id}",
                                    "type": "function",
                                    "function": {
                                        "name": tool_name,
                                        "arguments": '{"query": "test"}',
                                    },
                                }
                            ],
                        },
                        "logprobs": None,
                        "finish_reason": "tool_calls",
                    }
                ],
                "usage": {
                    "prompt_tokens": 0,
                    "completion_tokens": 0,
                    "total_tokens": 0,
                },
            }
        )

        events = [e async for e in provider._parse_openai_completion(completion)]
        assert len(events) == 1

        output_message = events[0].output_message
        assert output_message is not None
        assert output_message.tool_calls is not None
        assert len(output_message.tool_calls) == 1

        tool_call = output_message.tool_calls[0]
        assert tool_call.id == tool_call_id
        assert tool_call.function.name == tool_name
    finally:
        await provider.terminate()


@pytest.mark.asyncio
async def test_parse_openai_completion_dedupes_self_concatenated_tool_call_function_name_only():
    provider = _make_provider()
    try:
        tool_call_id = "call_95fae017db5b4a91b1259aba"
        tool_name = "astr_kb_search"
        completion = ChatCompletion.model_validate(
            {
                "id": "chatcmpl-toolcall-dup-name-only",
                "object": "chat.completion",
                "created": 0,
                "model": "gpt-4o-mini",
                "choices": [
                    {
                        "index": 0,
                        "message": {
                            "role": "assistant",
                            "content": None,
                            "refusal": None,
                            "tool_calls": [
                                {
                                    "id": tool_call_id,
                                    "type": "function",
                                    "function": {
                                        "name": f"{tool_name}{tool_name}",
                                        "arguments": '{"query": "test"}',
                                    },
                                }
                            ],
                        },
                        "logprobs": None,
                        "finish_reason": "tool_calls",
                    }
                ],
                "usage": {
                    "prompt_tokens": 0,
                    "completion_tokens": 0,
                    "total_tokens": 0,
                },
            }
        )

        events = [e async for e in provider._parse_openai_completion(completion)]
        assert len(events) == 1

        output_message = events[0].output_message
        assert output_message is not None
        assert output_message.tool_calls is not None
        assert len(output_message.tool_calls) == 1

        tool_call = output_message.tool_calls[0]
        assert tool_call.id == tool_call_id
        assert tool_call.function.name == tool_name
    finally:
        await provider.terminate()


@pytest.mark.asyncio
async def test_parse_openai_completion_dedupes_self_concatenated_tool_call_fields():
    provider = _make_provider()
    try:
        tool_call_id = "call_95fae017db5b4a91b1259aba"
        tool_name = "astr_kb_search"
        completion = ChatCompletion.model_validate(
            {
                "id": "chatcmpl-toolcall-dup",
                "object": "chat.completion",
                "created": 0,
                "model": "gpt-4o-mini",
                "choices": [
                    {
                        "index": 0,
                        "message": {
                            "role": "assistant",
                            "content": None,
                            "refusal": None,

The new tests assume:

  1. The helper under test is provider._parse_openai_completion and that it yields events with an output_message.tool_calls structure identical to the existing test.
  2. The existing test constructs tool_calls as shown (a list with id, type: "function", and function: {name, arguments}).

Please:

  • Ensure the signature and usage of provider._parse_openai_completion and the event/output_message shape match those used in test_parse_openai_completion_dedupes_self_concatenated_tool_call_fields. If the existing test uses a different helper or field path, mirror that in the two new tests.
  • Align the tool_calls payload shape (keys like "tool_calls", "type", "function", "arguments") with whatever is used in the existing dedupe test; update field names if they differ.

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

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.

Code Review

This pull request introduces a defensive de-duplication mechanism for tool call fields to handle issues where certain OpenAI-compatible proxies duplicate streaming chunks. It adds a helper function to detect and fix self-concatenated strings in tool IDs and function names, along with a corresponding test case. The review feedback suggests refactoring the nested helper function into a static method on the class to maintain consistency with the existing codebase structure and improve organization.

Comment on lines +848 to +852
def _dedupe_self_concatenated(value: str, *, min_len: int) -> str:
if not value or len(value) < min_len or (len(value) % 2) != 0:
return value
half = len(value) // 2
return value[:half] if value[:half] == value[half:] else value

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

For better code organization and consistency with other helper methods in this class (like _safe_json_dump), consider moving this nested function out of _parse_openai_completion and defining it as a staticmethod on the ProviderOpenAIOfficial class. This improves discoverability and aligns with the existing structure of the file.

After moving it, you would update the calls to self._dedupe_self_concatenated(...).

References
  1. Refactor logic into shared helper functions to improve code organization and reusability.

@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. and removed size:S This PR changes 10-29 lines, ignoring generated files. labels Apr 24, 2026
@x1051445024

Copy link
Copy Markdown
Contributor

[现场报告] 流式 tool_call 的 id/name 重复拼接在 v4.26.7 仍可复现 —— 附线上事故完整链路与流式侧防护方案

大家好,我们是一个长期运行的生产部署,昨天(2026-09-03)在 v4.26.7(openai 2.33.0)上线上实弹踩中了这个 bug,补充一份具体证据和方案建议,希望能帮上忙。

线上事故(litellm 网关,OpenAI 兼容协议)

一轮图片溯源 Agent 完全静默失效:

22:09:37 Agent 使用工具: ['reverse_image_searchreverse_image_search']
22:09:37 WARN 未找到指定的工具: reverse_image_searchreverse_image_search,将跳过。
22:13:03 openai.BadRequestError: 400 - litellm.BadRequestError:
        [StringParam] [input[8].call_id] [string_above_max_length]
        Invalid 'input[8].call_id': string too long. Expected max 64, got 86.
22:14:34 Agent 使用工具: ['brave_web_searchbrave_web_search']
22:14:34 WARN 未找到指定的工具: brave_web_searchbrave_web_search,将跳过。

同一根因引出两种故障形态:

  1. 工具名翻倍 → 工具注册表查不到 → error: Tool ... not found → 整个检索步骤静默降级(bot 只能对用户说"检索链路出了点问题");
  2. call_id 翻倍(86 字符)→ 随对话上下文持久化 → 下一轮请求直接被上游 400 拒收(litellm 执行 Responses API 的 64 字符上限)。这个次生影响比表面更糟:畸形消息会一直毒化会话,直到上下文压缩把它挤出去才自愈。

用 SDK 真实的累加器做过仿真确认机制:3 个 chunk 各自携带完整 id+nameaccumulate_delta 字符串拼接 → len(id) == 87,与线上观测到的 86+后缀 吻合。

现有两个 PR 的覆盖范围

#7735#7765 都是在完成解析时做事后去重,能覆盖常见情况,但事后自重复检测(s+ss)处理不了:

  • 3 次以上重复产生 abcabcabc——除非检测逻辑循环到稳定为止(建议确认两个 PR 是否都做了);
  • 同 index 复用且换了新 id —— 部分网关会复用 index=0 开启第二个 tool_call。SDK 快照按 index 合并,两个不同逻辑调用的 id/args 会被串接成一条(call_AAAcall_BBB)。这种情况下不存在自重复模式,[Bug]在查询的时候提示:未找到指定的工具: astr_kb_searchastr_kb_search,将跳过。 #7694 式的检测不会触发;
  • 字段重复但内容不一致(网关中途改写了 id 等)。

建议:把防护放到流式边界上

我们在 openai_source._query_stream 的流式循环里(紧挨着现有的 #6661 index 修复)打了一个补丁,用一个每次请求独立的 dict[int, tuple[id, name]] 做三件事:

  1. 同 index、相同 id/name 重复出现 → 把 delta 里的这两个字段丢弃,只保留 arguments 的增量拼接;
  2. 同 index、不同 id → 视为新的 tool_call,重映射到一个全新槽位(防止跨调用串接);
  3. 保留原有 1-based index 重映射。

已在真实 _query_stream 代码路径上端到端验证(假客户端重放恶意流,检查 ChatCompletionStreamState.get_final_completion 产出):正常流、双工具并行、与本线上事故完全一致的重复重放模式、同 index 换新 id,四种场景都产出干净的单条 tool_call。

这个方案在 state.handle_chunk 上游生效,SDK 快照完全见不到畸形 delta,不需要任何事后字符串手术。如果维护者倾向流式侧方案,我们乐意整理成 PR;或者它也可以和 #7735/#7765 互为纵深防御(每个 chunk 只是两次字典查询,开销可忽略)。

复现环境

  • AstrBot v4.26.7(Windows 原生部署),openai>=1.78.0 实际装到 2.33.0
  • Provider:openai_chat_completion,经 litellm 网关转发,该网关会间歇性地在 arguments chunk 里重复携带完整的 tool_call 元数据
  • 值得一提:当晚同一网关还有另一次不稳定(37 次 httpx.ReadTimeout),故障出现时 fallback 路由正在生效——fallback 会把网关的偶发怪癖放大成高频问题。

如需完整事故日志片段或最小复现脚本,随时可以提供。这个问题从 4 月(#7694)挂到现在,5 个月后仍在咬生产用户,希望这份 400 报错的实锤证据能帮两个修复 PR 早日合并。🙏

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]在查询的时候提示:未找到指定的工具: astr_kb_searchastr_kb_search,将跳过。

2 participants