Skip to content
Open
Changes from 1 commit
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
27 changes: 26 additions & 1 deletion astrbot/core/provider/sources/openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,32 @@ def _is_empty(content: Any) -> bool:

cleaned.append(msg)

payloads["messages"] = cleaned
# Drop orphaned tool messages whose assistant(tool_calls) was
# removed by context truncation / compression, preventing
# 400 "unexpected tool_use_id in tool_result" from the API.
valid_tc_ids = set()
final: list = []
_orphan_count = 0
for msg in cleaned:
if not isinstance(msg, dict):
final.append(msg)
continue
role = msg.get("role")
if role == "assistant" and msg.get("tool_calls"):
valid_tc_ids = {tc["id"] for tc in msg["tool_calls"] if isinstance(tc, dict) and "id" in tc}
final.append(msg)
elif role == "tool":
if msg.get("tool_call_id") in valid_tc_ids:
final.append(msg)
valid_tc_ids.discard(msg.get("tool_call_id"))
else:
_orphan_count += 1
else:
valid_tc_ids = set()
final.append(msg)
Comment on lines +566 to +589
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

The current implementation assumes that all messages in cleaned are dictionaries and that tool_calls contains only dictionary elements. If cleaned contains Message objects (or if tool_calls contains ToolCall objects), the code will either skip processing them or raise a TypeError / KeyError.

To make this sanitization robust and adhere to defensive programming practices, we should support both dictionary and object attribute access for role, tool_calls, and tool_call_id.

        valid_tc_ids = set()
        final: list = []
        _orphan_count = 0
        for msg in cleaned:
            role = msg.get("role") if isinstance(msg, dict) else getattr(msg, "role", None)
            tool_calls = msg.get("tool_calls") if isinstance(msg, dict) else getattr(msg, "tool_calls", None)
            
            if role == "assistant" and isinstance(tool_calls, list) and tool_calls:
                valid_tc_ids = {
                    tc["id"] if isinstance(tc, dict) else getattr(tc, "id", None)
                    for tc in tool_calls
                }
                valid_tc_ids.discard(None)
                final.append(msg)
            elif role == "tool":
                tool_call_id = msg.get("tool_call_id") if isinstance(msg, dict) else getattr(msg, "tool_call_id", None)
                if tool_call_id in valid_tc_ids:
                    final.append(msg)
                    valid_tc_ids.discard(tool_call_id)
                else:
                    _orphan_count += 1
            else:
                valid_tc_ids = set()
                final.append(msg)

if _orphan_count:
logger.debug("Filtered %d orphaned tool message(s)", _orphan_count)
payloads["messages"] = final

async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
if tools:
Expand Down
Loading