Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']) {
Expand Down Expand Up @@ -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']) ?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand All @@ -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());
}
}