diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 2fab40ca78..55dfa6213b 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -427,17 +427,30 @@ def to_openai_tool_calls_model(self) -> list[ToolCall]: """The same as to_openai_tool_calls but return pydantic model.""" ret = [] for idx, tool_call_arg in enumerate(self.tools_call_args): + # Some OpenAI-compatible gateways return tool_calls without a valid id + # (e.g. streaming snapshots misaligned by non-zero-based indexes). + # ToolCall.id is declared as str, so passing None would raise a pydantic + # ValidationError and fail the whole tool-calling turn. + raw_id = ( + self.tools_call_ids[idx] if idx < len(self.tools_call_ids) else None + ) + func_name = ( + self.tools_call_name[idx] if idx < len(self.tools_call_name) else None + ) + call_id = raw_id + if not isinstance(call_id, str) or not call_id: + call_id = f"call_{idx}" ret.append( ToolCall( - id=self.tools_call_ids[idx], + id=call_id, function=ToolCall.FunctionBody( - name=self.tools_call_name[idx], + name=func_name or "__malformed_tool_name__", arguments=json.dumps(tool_call_arg), ), # the extra_content will not serialize if it's None when calling ToolCall.model_dump() - extra_content=self.tools_call_extra_content.get( - self.tools_call_ids[idx] - ), + # Note: look up extra_content by the raw upstream id; the fallback id + # (call_{idx}) never exists in tools_call_extra_content. + extra_content=self.tools_call_extra_content.get(raw_id), ), ) return ret diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index f7870b7137..d7a653065f 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -638,6 +638,11 @@ async def _query_stream( state = ChatCompletionStreamState() + # 上游 tool_call.index -> 规范化后的 0 起始下标。 + # 必须是每次请求独立的局部状态,不能挂在 self 上, + # 否则并发请求会互相污染下标映射。 + tool_call_index_map: dict[int, int] = {} + async for chunk in stream: choice = chunk.choices[0] if chunk.choices else None delta = choice.delta if choice else None @@ -651,6 +656,21 @@ async def _query_stream( # Gemini and some OpenAI-compatible proxies omit this field if not hasattr(tc, "index") or tc.index is None: tc.index = idx + else: + # Some OpenAI-compatible gateways start tool_call.index at 1 + # (the OpenAI spec requires 0-based indexing). The SDK streaming + # snapshot accumulator uses index directly as a list subscript, + # so index=1 on an empty list raises IndexError and the tool_call + # name/arguments get split across misaligned slots, eventually + # producing a malformed tool_call with id=None/name="" that fails + # downstream ToolCall pydantic validation. + # Remap upstream indexes to a contiguous 0-based sequence. + remapped = tool_call_index_map.setdefault( + tc.index, + len(tool_call_index_map), + ) + if remapped != tc.index: + tc.index = remapped # 跳过 delta=None 的 chunk,避免 SDK 内部 _convert_initial_chunk_into_snapshot # 第 747 行 choice.delta.to_dict() 抛出 NoneType 错误。 # refs: AstrBot#6689 / openai-python#5069 / #5047