From 91a9d423482269ce69f7421e95b58be92870b5c8 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 30 Sep 2025 20:19:25 -0700 Subject: [PATCH] Enhance exception API for response exceptions from invalid values by including concrete field name. --- .../Http/Exception/ResponseException.php | 5 +++-- ...ctOpenAiCompatibleImageGenerationModel.php | 15 ++++++++----- ...actOpenAiCompatibleTextGenerationModel.php | 20 +++++++++++------- ...ckOpenAiCompatibleImageGenerationModel.php | 4 +++- ...enAiCompatibleImageGenerationModelTest.php | 16 +++++++------- ...penAiCompatibleTextGenerationModelTest.php | 21 ++++++++++++------- ...ockOpenAiCompatibleTextGenerationModel.php | 8 +++---- 7 files changed, 56 insertions(+), 33 deletions(-) diff --git a/src/Providers/Http/Exception/ResponseException.php b/src/Providers/Http/Exception/ResponseException.php index 65111ccb..334f485b 100644 --- a/src/Providers/Http/Exception/ResponseException.php +++ b/src/Providers/Http/Exception/ResponseException.php @@ -39,11 +39,12 @@ public static function fromMissingData(string $apiName, string $fieldName): self * @since n.e.x.t * * @param string $apiName The name of the API service (e.g., 'OpenAI', 'Anthropic'). + * @param string $fieldName The field that was invalid. * @param string $message The specific error message describing the invalid data. * @return self */ - public static function fromInvalidData(string $apiName, string $message): self + public static function fromInvalidData(string $apiName, string $fieldName, string $message): self { - return new self(sprintf('Unexpected %s API response: %s', $apiName, $message)); + return new self(sprintf('Unexpected %s API response: Invalid "%s" key: %s', $apiName, $fieldName, $message)); } } diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php index afe5a817..ff5d5105 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php @@ -302,20 +302,22 @@ protected function parseResponseToGenerativeAiResult( if (!is_array($responseData['data'])) { throw ResponseException::fromInvalidData( $this->providerMetadata()->getName(), - 'The data key must contain an array.' + 'data', + 'The value must be an array.' ); } $candidates = []; - foreach ($responseData['data'] as $choiceData) { + foreach ($responseData['data'] as $index => $choiceData) { if (!is_array($choiceData) || array_is_list($choiceData)) { throw ResponseException::fromInvalidData( $this->providerMetadata()->getName(), - 'Each element in the data key must be an associative array.' + "data[{$index}]", + 'The value must be an associative array.' ); } - $candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $expectedMimeType); + $candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $index, $expectedMimeType); } $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; @@ -352,12 +354,14 @@ protected function parseResponseToGenerativeAiResult( * @since 0.1.0 * * @param ChoiceData $choiceData The choice data from the API response. + * @param int $index The index of the choice in the choices array. * @param string $expectedMimeType The expected MIME type the response is in. * @return Candidate The parsed candidate. * @throws RuntimeException If the choice data is invalid. */ protected function parseResponseChoiceToCandidate( array $choiceData, + int $index, string $expectedMimeType = 'image/png' ): Candidate { if (isset($choiceData['url']) && is_string($choiceData['url'])) { @@ -367,7 +371,8 @@ protected function parseResponseChoiceToCandidate( } else { throw ResponseException::fromInvalidData( $this->providerMetadata()->getName(), - 'Each choice must contain either a url or b64_json key with a string value.' + "choices[{$index}]", + 'The value must contain either a url or b64_json key with a string value.' ); } diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index 456834ce..d7acb793 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -565,7 +565,8 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera if (!is_array($responseData['choices'])) { throw ResponseException::fromInvalidData( $this->providerMetadata()->getName(), - 'The choices key must contain an array.' + 'choices', + 'The value must be an array.' ); } @@ -574,7 +575,8 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera if (!is_array($choiceData) || array_is_list($choiceData)) { throw ResponseException::fromInvalidData( $this->providerMetadata()->getName(), - 'Each element in the choices key must be an associative array.' + "choices[{$index}]", + 'The value must be an associative array.' ); } @@ -640,7 +642,7 @@ protected function parseResponseChoiceToCandidate(array $choiceData, int $index) } $messageData = $choiceData['message']; - $message = $this->parseResponseChoiceMessage($messageData); + $message = $this->parseResponseChoiceMessage($messageData, $index); switch ($choiceData['finish_reason']) { case 'stop': @@ -658,6 +660,7 @@ protected function parseResponseChoiceToCandidate(array $choiceData, int $index) default: throw ResponseException::fromInvalidData( $this->providerMetadata()->getName(), + "choices[{$index}].finish_reason", sprintf('Invalid finish reason "%s".', $choiceData['finish_reason']) ); } @@ -671,15 +674,16 @@ protected function parseResponseChoiceToCandidate(array $choiceData, int $index) * @since 0.1.0 * * @param MessageData $messageData The message data from the API response. + * @param int $index The index of the choice in the choices array. * @return Message The parsed message. */ - protected function parseResponseChoiceMessage(array $messageData): Message + protected function parseResponseChoiceMessage(array $messageData, int $index): Message { $role = isset($messageData['role']) && 'user' === $messageData['role'] ? MessageRoleEnum::user() : MessageRoleEnum::model(); - $parts = $this->parseResponseChoiceMessageParts($messageData); + $parts = $this->parseResponseChoiceMessageParts($messageData, $index); return new Message($role, $parts); } @@ -690,9 +694,10 @@ protected function parseResponseChoiceMessage(array $messageData): Message * @since 0.1.0 * * @param MessageData $messageData The message data from the API response. + * @param int $index The index of the choice in the choices array. * @return MessagePart[] The parsed message parts. */ - protected function parseResponseChoiceMessageParts(array $messageData): array + protected function parseResponseChoiceMessageParts(array $messageData, int $index): array { $parts = []; @@ -705,11 +710,12 @@ protected function parseResponseChoiceMessageParts(array $messageData): array } if (isset($messageData['tool_calls']) && is_array($messageData['tool_calls'])) { - foreach ($messageData['tool_calls'] as $toolCallData) { + foreach ($messageData['tool_calls'] as $toolCallIndex => $toolCallData) { $toolCallPart = $this->parseResponseChoiceMessageToolCallPart($toolCallData); if (!$toolCallPart) { throw ResponseException::fromInvalidData( $this->providerMetadata()->getName(), + "choices[{$index}].message.tool_calls[{$toolCallIndex}]", 'The response includes a tool call of an unexpected type.' ); } diff --git a/tests/mocks/MockOpenAiCompatibleImageGenerationModel.php b/tests/mocks/MockOpenAiCompatibleImageGenerationModel.php index 81160be9..c9383082 100644 --- a/tests/mocks/MockOpenAiCompatibleImageGenerationModel.php +++ b/tests/mocks/MockOpenAiCompatibleImageGenerationModel.php @@ -91,13 +91,15 @@ public function exposeParseResponseToGenerativeAiResult( * Exposes the protected parseResponseChoiceToCandidate method. * * @param array $choiceData + * @param int $index * @param string $expectedMimeType * @return \WordPress\AiClient\Results\DTO\Candidate */ public function exposeParseResponseChoiceToCandidate( array $choiceData, + int $index, string $expectedMimeType = 'image/png' ): \WordPress\AiClient\Results\DTO\Candidate { - return $this->parseResponseChoiceToCandidate($choiceData, $expectedMimeType); + return $this->parseResponseChoiceToCandidate($choiceData, $index, $expectedMimeType); } } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php index 5b22121f..2f4b5e81 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php @@ -741,7 +741,9 @@ public function testParseResponseToGenerativeAiResultInvalidDataType(): void $model = $this->createModel(); $this->expectException(ResponseException::class); - $this->expectExceptionMessage('Unexpected TestProvider API response: The data key must contain an array.'); + $this->expectExceptionMessage( + 'Unexpected TestProvider API response: Invalid "data" key: The value must be an array.' + ); $model->exposeParseResponseToGenerativeAiResult($response); } @@ -758,7 +760,7 @@ public function testParseResponseToGenerativeAiResultInvalidChoiceElementType(): $this->expectException(ResponseException::class); $this->expectExceptionMessage( - 'Unexpected TestProvider API response: Each element in the data key must be an associative array.' + 'Unexpected TestProvider API response: Invalid "data[0]" key: The value must be an associative array.' ); $model->exposeParseResponseToGenerativeAiResult($response); @@ -775,7 +777,7 @@ public function testParseResponseChoiceToCandidateValidUrlData(): void 'url' => 'https://example.com/image.png', ]; $model = $this->createModel(); - $candidate = $model->exposeParseResponseChoiceToCandidate($choiceData, 'image/png'); + $candidate = $model->exposeParseResponseChoiceToCandidate($choiceData, 0, 'image/png'); $this->assertInstanceOf(Candidate::class, $candidate); $this->assertEquals( @@ -799,7 +801,7 @@ public function testParseResponseChoiceToCandidateValidB64JsonData(): void 'b64_json' => $base64Image, ]; $model = $this->createModel(); - $candidate = $model->exposeParseResponseChoiceToCandidate($choiceData, 'image/png'); + $candidate = $model->exposeParseResponseChoiceToCandidate($choiceData, 0, 'image/png'); $this->assertInstanceOf(Candidate::class, $candidate); $this->assertEquals($base64Image, $candidate->getMessage()->getParts()[0]->getFile()->getBase64Data()); @@ -822,10 +824,10 @@ public function testParseResponseChoiceToCandidateMissingUrlOrB64Json(): void $this->expectException(ResponseException::class); $this->expectExceptionMessage( - 'Unexpected TestProvider API response: Each choice must contain either a url or b64_json key with a ' . - 'string value.' + 'Unexpected TestProvider API response: Invalid "choices[0]" key: The value must contain either a ' . + 'url or b64_json key with a string value.' ); - $model->exposeParseResponseChoiceToCandidate($choiceData); + $model->exposeParseResponseChoiceToCandidate($choiceData, 0); } } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 1cc9596a..813721a3 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -972,7 +972,9 @@ public function testParseResponseToGenerativeAiResultInvalidChoicesType(): void $model = $this->createModel(); $this->expectException(ResponseException::class); - $this->expectExceptionMessage('Unexpected TestProvider API response: The choices key must contain an array.'); + $this->expectExceptionMessage( + 'Unexpected TestProvider API response: Invalid "choices" key: The value must be an array.' + ); $model->parseResponseToGenerativeAiResult($response); } @@ -989,7 +991,7 @@ public function testParseResponseToGenerativeAiResultInvalidChoiceElementType(): $this->expectException(ResponseException::class); $this->expectExceptionMessage( - 'Unexpected TestProvider API response: Each element in the choices key must be an associative array.' + 'Unexpected TestProvider API response: Invalid "choices[0]" key: The value must be an associative array.' ); $model->parseResponseToGenerativeAiResult($response); @@ -1098,7 +1100,12 @@ public function testParseResponseChoiceToCandidateInvalidFinishReason(): void $model = $this->createModel(); $this->expectException(ResponseException::class); - $this->expectExceptionMessage('Unexpected TestProvider API response: Invalid finish reason "unknown".'); + $this->expectExceptionMessage( + sprintf( + 'Unexpected TestProvider API response: Invalid "%s" key: Invalid finish reason "unknown".', + 'choices[0].finish_reason' + ) + ); $model->exposeParseResponseChoiceToCandidate($choiceData); } @@ -1115,7 +1122,7 @@ public function testParseResponseChoiceMessageAssistant(): void 'content' => 'Assistant response', ]; $model = $this->createModel(); - $message = $model->exposeParseResponseChoiceMessage($messageData); + $message = $model->exposeParseResponseChoiceMessage($messageData, 0); $this->assertEquals(MessageRoleEnum::model(), $message->getRole()); $this->assertCount(1, $message->getParts()); @@ -1134,7 +1141,7 @@ public function testParseResponseChoiceMessageUser(): void 'content' => 'User response', ]; $model = $this->createModel(); - $message = $model->exposeParseResponseChoiceMessage($messageData); + $message = $model->exposeParseResponseChoiceMessage($messageData, 0); $this->assertEquals(MessageRoleEnum::user(), $message->getRole()); $this->assertCount(1, $message->getParts()); @@ -1153,7 +1160,7 @@ public function testParseResponseChoiceMessagePartsContentAndReasoning(): void 'content' => 'Final answer', ]; $model = $this->createModel(); - $parts = $model->exposeParseResponseChoiceMessageParts($messageData); + $parts = $model->exposeParseResponseChoiceMessageParts($messageData, 0); $this->assertCount(2, $parts); $this->assertEquals('Thinking process', $parts[0]->getText()); @@ -1182,7 +1189,7 @@ public function testParseResponseChoiceMessagePartsToolCalls(): void ], ]; $model = $this->createModel(); - $parts = $model->exposeParseResponseChoiceMessageParts($messageData); + $parts = $model->exposeParseResponseChoiceMessageParts($messageData, 0); $this->assertCount(1, $parts); $this->assertInstanceOf(FunctionCall::class, $parts[0]->getFunctionCall()); diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php b/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php index 49decc57..3e33c61f 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php @@ -163,14 +163,14 @@ public function exposeParseResponseChoiceToCandidate(array $choiceData, int $ind return $this->parseResponseChoiceToCandidate($choiceData, $index); } - public function exposeParseResponseChoiceMessage(array $messageData): Message + public function exposeParseResponseChoiceMessage(array $messageData, int $index = 0): Message { - return $this->parseResponseChoiceMessage($messageData); + return $this->parseResponseChoiceMessage($messageData, $index); } - public function exposeParseResponseChoiceMessageParts(array $messageData): array + public function exposeParseResponseChoiceMessageParts(array $messageData, int $index = 0): array { - return $this->parseResponseChoiceMessageParts($messageData); + return $this->parseResponseChoiceMessageParts($messageData, $index); } public function exposeParseResponseChoiceMessageToolCallPart(array $toolCallData): ?MessagePart