diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index e0e2e71b..72ccab25 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -6,6 +6,7 @@ use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; +use WordPress\AiClient\Common\Exception\TokenLimitReachedException; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum; @@ -658,6 +659,23 @@ protected function parseResponseChoiceToCandidate(array $choiceData, int $index) } $messageData = $choiceData['message']; + + if ( + 'length' === $choiceData['finish_reason'] + && isset($messageData['tool_calls']) + && is_array($messageData['tool_calls']) + && count($messageData['tool_calls']) > 0 + ) { + throw new TokenLimitReachedException( + sprintf( + '%s response was truncated while generating a tool call.' + . ' Increase the max tokens or simplify the prompt.', + $this->providerMetadata()->getName() + ), + $this->getConfig()->getMaxTokens() + ); + } + $message = $this->parseResponseChoiceMessage($messageData, $index); switch ($choiceData['finish_reason']) { @@ -765,9 +783,18 @@ protected function parseResponseChoiceMessageToolCallPart(array $toolCallData): return null; } - $functionArguments = is_string($toolCallData['function']['arguments']) - ? json_decode($toolCallData['function']['arguments'], true) - : $toolCallData['function']['arguments']; + if (is_string($toolCallData['function']['arguments'])) { + $functionArguments = json_decode($toolCallData['function']['arguments'], true); + if (null === $functionArguments && JSON_ERROR_NONE !== json_last_error()) { + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + 'tool_call.function.arguments', + sprintf('Invalid JSON in tool call arguments: %s', json_last_error_msg()) + ); + } + } else { + $functionArguments = $toolCallData['function']['arguments']; + } $functionCall = new FunctionCall( isset($toolCallData['id']) && is_string($toolCallData['id']) ? diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 6c99c75b..aa900aee 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Exception\TokenLimitReachedException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; @@ -1314,6 +1315,54 @@ public function testParseResponseChoiceMessageToolCallPartNonFunctionType(): voi $this->assertNull($part); } + /** + * Tests parseResponseChoiceMessageToolCallPart() throws ResponseException + * when tool call arguments contain invalid JSON. + * + * @return void + */ + public function testParseResponseChoiceMessageToolCallPartInvalidJsonArguments(): void + { + $toolCallData = [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'test_function', + 'arguments' => '{invalid json', + ], + ]; + $model = $this->createModel(); + + $this->expectException(ResponseException::class); + $this->expectExceptionMessage('Invalid JSON in tool call arguments'); + + $model->exposeParseResponseChoiceMessageToolCallPart($toolCallData); + } + + /** + * Tests parseResponseChoiceMessageToolCallPart() handles non-string + * (already decoded) arguments. + * + * @return void + */ + public function testParseResponseChoiceMessageToolCallPartArrayArguments(): void + { + $toolCallData = [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'test_function', + 'arguments' => ['key' => 'value'], + ], + ]; + $model = $this->createModel(); + $part = $model->exposeParseResponseChoiceMessageToolCallPart($toolCallData); + + $this->assertInstanceOf(MessagePart::class, $part); + $this->assertInstanceOf(FunctionCall::class, $part->getFunctionCall()); + $this->assertEquals(['key' => 'value'], $part->getFunctionCall()->getArgs()); + } + /** * Tests getMessagePartContentData() with text part in thought channel. * @@ -1328,4 +1377,128 @@ public function testGetMessagePartContentDataThoughtPart(): void // Should be skipped because OpenAI API doesn't support receiving thoughts. $this->assertNull($data); } + + /** + * Tests parseResponseChoiceToCandidate() throws TokenLimitReachedException + * when finish_reason is 'length' and tool calls are present. + * + * @return void + */ + public function testParseResponseChoiceToCandidateThrowsOnLengthWithToolCalls(): void + { + $choiceData = [ + 'message' => [ + 'role' => 'assistant', + 'content' => null, + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'get_weather', + 'arguments' => '{"location": "Lon', + ], + ], + ], + ], + 'finish_reason' => 'length', + ]; + $model = $this->createModel(); + + $this->expectException(TokenLimitReachedException::class); + $this->expectExceptionMessage('TestProvider response was truncated while generating a tool call.'); + + $model->exposeParseResponseChoiceToCandidate($choiceData); + } + + /** + * Tests parseResponseChoiceToCandidate() includes maxTokens in the + * TokenLimitReachedException when configured. + * + * @return void + */ + public function testParseResponseChoiceToCandidateTokenLimitIncludesMaxTokens(): void + { + $choiceData = [ + 'message' => [ + 'role' => 'assistant', + 'content' => null, + 'tool_calls' => [ + [ + 'id' => 'call_456', + 'type' => 'function', + 'function' => [ + 'name' => 'get_weather', + 'arguments' => '{"loc', + ], + ], + ], + ], + 'finish_reason' => 'length', + ]; + $modelConfig = new ModelConfig(); + $modelConfig->setMaxTokens(500); + $model = $this->createModel($modelConfig); + + try { + $model->exposeParseResponseChoiceToCandidate($choiceData); + $this->fail('Expected TokenLimitReachedException was not thrown.'); + } catch (TokenLimitReachedException $e) { + $this->assertSame(500, $e->getMaxTokens()); + } + } + + /** + * Tests parseResponseChoiceToCandidate() does not throw when + * finish_reason is 'length' but no tool calls are present. + * + * @return void + */ + public function testParseResponseChoiceToCandidateLengthWithoutToolCallsDoesNotThrow(): void + { + $choiceData = [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'This is a truncated respon', + ], + 'finish_reason' => 'length', + ]; + $model = $this->createModel(); + $candidate = $model->exposeParseResponseChoiceToCandidate($choiceData); + + $this->assertInstanceOf(Candidate::class, $candidate); + $this->assertEquals(FinishReasonEnum::length(), $candidate->getFinishReason()); + } + + /** + * Tests parseResponseChoiceToCandidate() does not throw when + * finish_reason is 'tool_calls' with valid tool calls. + * + * @return void + */ + public function testParseResponseChoiceToCandidateToolCallsFinishReasonDoesNotThrow(): void + { + $choiceData = [ + 'message' => [ + 'role' => 'assistant', + 'content' => null, + 'tool_calls' => [ + [ + 'id' => 'call_789', + 'type' => 'function', + 'function' => [ + 'name' => 'get_weather', + 'arguments' => '{"location":"London"}', + ], + ], + ], + ], + 'finish_reason' => 'tool_calls', + ]; + $model = $this->createModel(); + $candidate = $model->exposeParseResponseChoiceToCandidate($choiceData); + + $this->assertInstanceOf(Candidate::class, $candidate); + $this->assertEquals(FinishReasonEnum::toolCalls(), $candidate->getFinishReason()); + } }