From b1a5c47a283ec31bd8fdf223f26d0008f8abc60f Mon Sep 17 00:00:00 2001 From: James LePage <36246732+Jameswlepage@users.noreply.github.com> Date: Mon, 1 Sep 2025 20:39:31 -0400 Subject: [PATCH 1/3] fix: handle empty tool calls and arguments in OpenAI compatible models - Only include tool_calls field when there are actual tool calls (OpenAI rejects empty arrays) - Convert empty argument arrays to objects in JSON encoding (both OpenAI and Anthropic require objects) - Add comprehensive test coverage for both edge cases This fixes critical API failures when AI models make function/tool calls with no parameters, making the SDK production-ready for parameterless functions like those in WordPress Abilities API. --- ...actOpenAiCompatibleTextGenerationModel.php | 24 +++++++--- ...penAiCompatibleTextGenerationModelTest.php | 46 +++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index f48fac24..12809892 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -236,17 +236,24 @@ function (Message $message): array { 'tool_call_id' => $functionResponse->getId(), ]; } - return [ + $messageData = [ 'role' => $this->getMessageRoleString($message->getRole()), 'content' => array_values(array_filter(array_map( [$this, 'getMessagePartContentData'], $messageParts ))), - 'tool_calls' => array_values(array_filter(array_map( - [$this, 'getMessagePartToolCallData'], - $messageParts - ))), ]; + + // Only include tool_calls if there are any (OpenAI rejects empty arrays). + $toolCalls = array_values(array_filter(array_map( + [$this, 'getMessagePartToolCallData'], + $messageParts + ))); + if (!empty($toolCalls)) { + $messageData['tool_calls'] = $toolCalls; + } + + return $messageData; }, $messages ); @@ -394,12 +401,17 @@ protected function getMessagePartToolCallData(MessagePart $part): ?array 'The function call typed message part must contain a function call.' ); } + $args = $functionCall->getArgs(); + // Ensure empty arrays become empty objects for JSON encoding. + if (is_array($args) && empty($args)) { + $args = (object) array(); + } return [ 'type' => 'function', 'id' => $functionCall->getId(), 'function' => [ 'name' => $functionCall->getName(), - 'arguments' => json_encode($functionCall->getArgs()), + 'arguments' => json_encode($args), ], ]; } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index cae0b6a9..83f4da8b 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -518,6 +518,26 @@ public function testPrepareMessagesParamModelMessageWithFunctionCall(): void ); } + /** + * Tests prepareMessagesParam with message having no function calls (tool_calls should not be included). + * + * @return void + */ + public function testPrepareMessagesParamNoToolCalls(): void + { + $message = new Message( + MessageRoleEnum::model(), + [new MessagePart('Hello, I am a simple text response.')] + ); + + $model = $this->createModel(); + $prepared = $model->exposePrepareMessagesParam([$message], null); + + $this->assertCount(1, $prepared); + $this->assertEquals('assistant', $prepared[0]['role']); + $this->assertArrayNotHasKey('tool_calls', $prepared[0]); // Should not have tool_calls field at all + } + /** * Tests prepareMessagesParam() with function response. * @@ -742,6 +762,32 @@ public function testGetMessagePartToolCallDataFunctionCallPart(): void ], $data); } + /** + * Tests getMessagePartToolCallData() with empty arguments (should encode as empty object). + * + * @return void + */ + public function testGetMessagePartToolCallDataEmptyArguments(): void + { + $functionCall = new FunctionCall( + 'call_1', + 'list_capabilities', + [] // Empty arguments array + ); + $part = new MessagePart($functionCall); + $model = $this->createModel(); + $data = $model->exposeGetMessagePartToolCallData($part); + + $this->assertEquals([ + 'type' => 'function', + 'id' => 'call_1', + 'function' => [ + 'name' => 'list_capabilities', + 'arguments' => '{}', // Should be empty object, not empty array + ], + ], $data); + } + /** * Tests getMessagePartToolCallData() with text part (should return null). * From f043ca65314a523cb6cc0beee482940344fa2504 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 14 Oct 2025 20:55:37 -0700 Subject: [PATCH 2/3] Fix PHPCS errors. --- .../AbstractOpenAiCompatibleTextGenerationModelTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 9dbec5c8..c1e8c02f 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -531,10 +531,10 @@ public function testPrepareMessagesParamNoToolCalls(): void MessageRoleEnum::model(), [new MessagePart('Hello, I am a simple text response.')] ); - + $model = $this->createModel(); $prepared = $model->exposePrepareMessagesParam([$message], null); - + $this->assertCount(1, $prepared); $this->assertEquals('assistant', $prepared[0]['role']); $this->assertArrayNotHasKey('tool_calls', $prepared[0]); // Should not have tool_calls field at all From 133e576cf1e1b4db0aa27e2904bcd02d4f3cd413 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 14 Oct 2025 21:01:19 -0700 Subject: [PATCH 3/3] Clarify empty array to object conversion for OpenAI API compatible tool call. --- ...bstractOpenAiCompatibleTextGenerationModel.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index a26304ad..1f345c96 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -406,10 +406,19 @@ protected function getMessagePartToolCallData(MessagePart $part): ?array ); } $args = $functionCall->getArgs(); - // Ensure empty arrays become empty objects for JSON encoding. - if (is_array($args) && empty($args)) { - $args = (object) array(); + + /* + * Ensure empty arrays become empty objects for JSON encoding. + * While in theory the JSON schema could also dictate a type of + * 'array', in practice function arguments are typically of type + * 'object'. More importantly, the OpenAI API specification seems + * to expect that, and does not support passing arrays as the root + * value. + */ + if (is_array($args) && count($args) === 0) { + $args = new \stdClass(); } + return [ 'type' => 'function', 'id' => $functionCall->getId(),