From 19b0b50cc43a29a8ec9ae6a2df6760928e71fdf6 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Mon, 1 Sep 2025 16:11:39 +0300 Subject: [PATCH 01/39] Implement simplified exception hierarchy for better error handling --- src/AiClient.php | 6 +- src/Exceptions/AiClientExceptionInterface.php | 19 ++++++ src/Exceptions/InvalidArgumentException.php | 17 ++++++ src/Exceptions/NetworkException.php | 19 ++++++ src/Exceptions/RequestException.php | 19 ++++++ src/Exceptions/RuntimeException.php | 17 ++++++ .../Http/Exception/ResponseException.php | 4 +- src/Results/DTO/GenerativeAiResult.php | 4 +- src/Tools/DTO/FunctionResponse.php | 2 +- tests/unit/Exceptions/ExceptionsTest.php | 60 +++++++++++++++++++ 10 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 src/Exceptions/AiClientExceptionInterface.php create mode 100644 src/Exceptions/InvalidArgumentException.php create mode 100644 src/Exceptions/NetworkException.php create mode 100644 src/Exceptions/RequestException.php create mode 100644 src/Exceptions/RuntimeException.php create mode 100644 tests/unit/Exceptions/ExceptionsTest.php diff --git a/src/AiClient.php b/src/AiClient.php index 67bf36c8..98290a96 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -5,6 +5,8 @@ namespace WordPress\AiClient; use WordPress\AiClient\Builders\PromptBuilder; +use WordPress\AiClient\Exceptions\InvalidArgumentException; +use WordPress\AiClient\Exceptions\RuntimeException; use WordPress\AiClient\ProviderImplementations\Anthropic\AnthropicProvider; use WordPress\AiClient\ProviderImplementations\Google\GoogleProvider; use WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider; @@ -282,7 +284,7 @@ public static function generateSpeechResult( */ public static function message(?string $text = null) { - throw new \RuntimeException( + throw new RuntimeException( 'MessageBuilder is not yet available. This method depends on builder infrastructure. ' . 'Use direct generation methods (generateTextResult, generateImageResult, etc.) for now.' ); @@ -302,7 +304,7 @@ private static function validateModelOrConfigParameter($modelOrConfig): void && !$modelOrConfig instanceof ModelInterface && !$modelOrConfig instanceof ModelConfig ) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( 'Parameter must be a ModelInterface instance (specific model), ' . 'ModelConfig instance (for auto-discovery), or null (default auto-discovery). ' . sprintf('Received: %s', is_object($modelOrConfig) ? get_class($modelOrConfig) : gettype($modelOrConfig)) diff --git a/src/Exceptions/AiClientExceptionInterface.php b/src/Exceptions/AiClientExceptionInterface.php new file mode 100644 index 00000000..82c351c4 --- /dev/null +++ b/src/Exceptions/AiClientExceptionInterface.php @@ -0,0 +1,19 @@ +assertInstanceOf(AiClientExceptionInterface::class, $exception); + } + } + + public function testCatchAllFunctionality(): void + { + $exceptions = [ + new InvalidArgumentException('invalid error'), + new RuntimeException('runtime error'), + new NetworkException('network error'), + new RequestException('request error'), + ]; + + foreach ($exceptions as $exception) { + $caught = false; + try { + throw $exception; + } catch (AiClientExceptionInterface $e) { + $caught = true; + $this->assertStringContainsString('error', $e->getMessage()); + } + $this->assertTrue($caught, 'Exception should be catchable as AiClientExceptionInterface'); + } + } +} \ No newline at end of file From f1b23d3423eaf85d6af16dd6f2aead803230c936 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Mon, 1 Sep 2025 16:21:20 +0300 Subject: [PATCH 02/39] Fix code style violations --- src/Exceptions/AiClientExceptionInterface.php | 2 +- src/Exceptions/InvalidArgumentException.php | 2 +- src/Exceptions/NetworkException.php | 2 +- src/Exceptions/RequestException.php | 2 +- src/Exceptions/RuntimeException.php | 2 +- src/Results/DTO/GenerativeAiResult.php | 2 +- src/Tools/DTO/FunctionResponse.php | 2 +- tests/unit/Exceptions/ExceptionsTest.php | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Exceptions/AiClientExceptionInterface.php b/src/Exceptions/AiClientExceptionInterface.php index 82c351c4..7cc35d9f 100644 --- a/src/Exceptions/AiClientExceptionInterface.php +++ b/src/Exceptions/AiClientExceptionInterface.php @@ -16,4 +16,4 @@ */ interface AiClientExceptionInterface extends Throwable { -} \ No newline at end of file +} diff --git a/src/Exceptions/InvalidArgumentException.php b/src/Exceptions/InvalidArgumentException.php index dae39a1c..e2d6dd13 100644 --- a/src/Exceptions/InvalidArgumentException.php +++ b/src/Exceptions/InvalidArgumentException.php @@ -14,4 +14,4 @@ */ class InvalidArgumentException extends \InvalidArgumentException implements AiClientExceptionInterface { -} \ No newline at end of file +} diff --git a/src/Exceptions/NetworkException.php b/src/Exceptions/NetworkException.php index ff9f0190..b7ed9a7d 100644 --- a/src/Exceptions/NetworkException.php +++ b/src/Exceptions/NetworkException.php @@ -16,4 +16,4 @@ */ class NetworkException extends RuntimeException implements AiClientExceptionInterface { -} \ No newline at end of file +} diff --git a/src/Exceptions/RequestException.php b/src/Exceptions/RequestException.php index 1708204e..ed658112 100644 --- a/src/Exceptions/RequestException.php +++ b/src/Exceptions/RequestException.php @@ -16,4 +16,4 @@ */ class RequestException extends RuntimeException implements AiClientExceptionInterface { -} \ No newline at end of file +} diff --git a/src/Exceptions/RuntimeException.php b/src/Exceptions/RuntimeException.php index 9f6f4f3d..f71c596e 100644 --- a/src/Exceptions/RuntimeException.php +++ b/src/Exceptions/RuntimeException.php @@ -14,4 +14,4 @@ */ class RuntimeException extends \RuntimeException implements AiClientExceptionInterface { -} \ No newline at end of file +} diff --git a/src/Results/DTO/GenerativeAiResult.php b/src/Results/DTO/GenerativeAiResult.php index 4fd63ed0..223ba5fd 100644 --- a/src/Results/DTO/GenerativeAiResult.php +++ b/src/Results/DTO/GenerativeAiResult.php @@ -4,9 +4,9 @@ namespace WordPress\AiClient\Results\DTO; +use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Exceptions\InvalidArgumentException; use WordPress\AiClient\Exceptions\RuntimeException; -use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Providers\DTO\ProviderMetadata; diff --git a/src/Tools/DTO/FunctionResponse.php b/src/Tools/DTO/FunctionResponse.php index 7a3135df..c351c02f 100644 --- a/src/Tools/DTO/FunctionResponse.php +++ b/src/Tools/DTO/FunctionResponse.php @@ -4,8 +4,8 @@ namespace WordPress\AiClient\Tools\DTO; -use WordPress\AiClient\Exceptions\InvalidArgumentException; use WordPress\AiClient\Common\AbstractDataTransferObject; +use WordPress\AiClient\Exceptions\InvalidArgumentException; /** * Represents a response to a function call. diff --git a/tests/unit/Exceptions/ExceptionsTest.php b/tests/unit/Exceptions/ExceptionsTest.php index 955fa38e..e6364350 100644 --- a/tests/unit/Exceptions/ExceptionsTest.php +++ b/tests/unit/Exceptions/ExceptionsTest.php @@ -57,4 +57,4 @@ public function testCatchAllFunctionality(): void $this->assertTrue($caught, 'Exception should be catchable as AiClientExceptionInterface'); } } -} \ No newline at end of file +} From a22c94e4526e2c0f38e9e279453756270e9a5385 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 3 Sep 2025 00:54:48 +0300 Subject: [PATCH 03/39] Implement exception hierarchy reorganization with static factory methods --- src/AiClient.php | 4 +- .../Exception}/InvalidArgumentException.php | 4 +- .../Exception}/RuntimeException.php | 4 +- src/Exceptions/NetworkException.php | 19 --- src/Exceptions/RequestException.php | 19 --- src/Messages/DTO/Message.php | 4 +- .../OpenAi/OpenAiModelMetadataDirectory.php | 5 +- .../Http/Exception/NetworkException.php | 109 ++++++++++++++++++ .../Http/Exception/RequestException.php | 77 +++++++++++++ .../Http/Exception/ResponseException.php | 85 +++++++++++++- src/Providers/Http/HttpTransporter.php | 24 +++- src/Results/DTO/GenerativeAiResult.php | 4 +- src/Tools/DTO/FunctionResponse.php | 2 +- tests/unit/Exceptions/ExceptionsTest.php | 16 +-- 14 files changed, 315 insertions(+), 61 deletions(-) rename src/{Exceptions => Common/Exception}/InvalidArgumentException.php (77%) rename src/{Exceptions => Common/Exception}/RuntimeException.php (75%) delete mode 100644 src/Exceptions/NetworkException.php delete mode 100644 src/Exceptions/RequestException.php create mode 100644 src/Providers/Http/Exception/NetworkException.php create mode 100644 src/Providers/Http/Exception/RequestException.php diff --git a/src/AiClient.php b/src/AiClient.php index 98290a96..d100d8e3 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -5,8 +5,8 @@ namespace WordPress\AiClient; use WordPress\AiClient\Builders\PromptBuilder; -use WordPress\AiClient\Exceptions\InvalidArgumentException; -use WordPress\AiClient\Exceptions\RuntimeException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\ProviderImplementations\Anthropic\AnthropicProvider; use WordPress\AiClient\ProviderImplementations\Google\GoogleProvider; use WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider; diff --git a/src/Exceptions/InvalidArgumentException.php b/src/Common/Exception/InvalidArgumentException.php similarity index 77% rename from src/Exceptions/InvalidArgumentException.php rename to src/Common/Exception/InvalidArgumentException.php index e2d6dd13..bb9fb949 100644 --- a/src/Exceptions/InvalidArgumentException.php +++ b/src/Common/Exception/InvalidArgumentException.php @@ -2,7 +2,9 @@ declare(strict_types=1); -namespace WordPress\AiClient\Exceptions; +namespace WordPress\AiClient\Common\Exception; + +use WordPress\AiClient\Exceptions\AiClientExceptionInterface; /** * Exception thrown when an invalid argument is provided. diff --git a/src/Exceptions/RuntimeException.php b/src/Common/Exception/RuntimeException.php similarity index 75% rename from src/Exceptions/RuntimeException.php rename to src/Common/Exception/RuntimeException.php index f71c596e..e7685c24 100644 --- a/src/Exceptions/RuntimeException.php +++ b/src/Common/Exception/RuntimeException.php @@ -2,7 +2,9 @@ declare(strict_types=1); -namespace WordPress\AiClient\Exceptions; +namespace WordPress\AiClient\Common\Exception; + +use WordPress\AiClient\Exceptions\AiClientExceptionInterface; /** * Exception thrown for runtime errors. diff --git a/src/Exceptions/NetworkException.php b/src/Exceptions/NetworkException.php deleted file mode 100644 index b7ed9a7d..00000000 --- a/src/Exceptions/NetworkException.php +++ /dev/null @@ -1,19 +0,0 @@ -value); + throw new InvalidArgumentException('Invalid message role: ' . $role->value); } } } diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index fd366192..ab9eca13 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; use RuntimeException; +use WordPress\AiClient\Providers\Http\Exception\ResponseException; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; @@ -53,9 +54,7 @@ protected function parseResponseToModelMetadataList(Response $response): array /** @var ModelsResponseData $responseData */ $responseData = $response->getData(); if (!isset($responseData['data']) || !$responseData['data']) { - throw new RuntimeException( - 'Unexpected API response: Missing the data key.' - ); + throw ResponseException::fromMissingData('OpenAI', 'data'); } // Unfortunately, the OpenAI API does not return model capabilities, so we have to hardcode them here. diff --git a/src/Providers/Http/Exception/NetworkException.php b/src/Providers/Http/Exception/NetworkException.php new file mode 100644 index 00000000..34a2e0fc --- /dev/null +++ b/src/Providers/Http/Exception/NetworkException.php @@ -0,0 +1,109 @@ +getMessage() + ); + + return new self($message, 0, $networkException); + } + + /** + * Creates a NetworkException for DNS resolution failures. + * + * @since 0.2.0 + * + * @param string $hostname The hostname that failed to resolve. + * @param \Throwable|null $previous The underlying DNS exception. + * @return self + */ + public static function fromDnsFailure(string $hostname, ?\Throwable $previous = null): self + { + $message = sprintf('Failed to resolve hostname: %s', $hostname); + + return new self($message, 0, $previous); + } + + /** + * Creates a NetworkException for SSL/TLS errors. + * + * @since 0.2.0 + * + * @param string $uri The URI with SSL/TLS issues. + * @param string $sslError Description of the SSL/TLS error. + * @param \Throwable|null $previous The underlying SSL exception. + * @return self + */ + public static function fromSslError(string $uri, string $sslError, ?\Throwable $previous = null): self + { + $message = sprintf('SSL/TLS error for %s: %s', $uri, $sslError); + + return new self($message, 0, $previous); + } +} diff --git a/src/Providers/Http/Exception/RequestException.php b/src/Providers/Http/Exception/RequestException.php new file mode 100644 index 00000000..936e4b00 --- /dev/null +++ b/src/Providers/Http/Exception/RequestException.php @@ -0,0 +1,77 @@ +getBody(); + $errorDetail = $body ? substr($body, 0, 200) : 'Invalid request parameters'; + + $message = sprintf( + 'Bad request to %s API (400): %s', + $apiName, + $errorDetail + ); + + return new self($message); + } + + /** + * Creates a RequestException from a bad request to a specific URI. + * + * @since 0.2.0 + * + * @param string $uri The URI that was requested. + * @param string $errorDetail Details about what made the request bad. + * @return self + */ + public static function fromBadRequestToUri(string $uri, string $errorDetail = 'Invalid request parameters'): self + { + return new self(sprintf('Bad request to %s (400): %s', $uri, $errorDetail)); + } +} diff --git a/src/Providers/Http/Exception/ResponseException.php b/src/Providers/Http/Exception/ResponseException.php index ab3b2b89..37e686d0 100644 --- a/src/Providers/Http/Exception/ResponseException.php +++ b/src/Providers/Http/Exception/ResponseException.php @@ -4,13 +4,94 @@ namespace WordPress\AiClient\Providers\Http\Exception; -use WordPress\AiClient\Exceptions\RequestException; +use WordPress\AiClient\Common\Exception\RuntimeException; +use WordPress\AiClient\Providers\Http\DTO\Response; /** * Exception class for HTTP response errors. * + * This is used when response data is unexpected or malformed, + * typically indicating that a provider changed in ways our code + * is not aware of or when parsing response data fails. + * * @since 0.1.0 */ -class ResponseException extends RequestException +class ResponseException extends RuntimeException { + /** + * Creates a ResponseException for missing expected data. + * + * @since 0.2.0 + * + * @param string $apiName The name of the API/provider. + * @param string $fieldName The field that was expected but missing. + * @param string $context Additional context about where the field was expected. + * @return self + */ + public static function fromMissingData(string $apiName, string $fieldName, string $context = ''): self + { + $message = sprintf('Unexpected %s API response: Missing the "%s" key', $apiName, $fieldName); + if ($context !== '') { + $message .= ' in ' . $context; + } + $message .= '.'; + + return new self($message); + } + + /** + * Creates a ResponseException for unexpected API response structure. + * + * @since 0.2.0 + * + * @param string $apiName The name of the API/provider. + * @param string $expected What structure was expected. + * @param string $actual What was actually received. + * @return self + */ + public static function fromUnexpectedStructure(string $apiName, string $expected, string $actual = 'unknown'): self + { + return new self(sprintf( + 'Unexpected %s API response structure. Expected: %s, Got: %s', + $apiName, + $expected, + $actual + )); + } + + /** + * Creates a ResponseException for malformed response data. + * + * @since 0.2.0 + * + * @param string $apiName The name of the API/provider. + * @param string $reason Why the response is considered malformed. + * @param Response|null $response The response object if available. + * @return self + */ + public static function fromMalformedResponse(string $apiName, string $reason, ?Response $response = null): self + { + $message = sprintf('Malformed %s API response: %s', $apiName, $reason); + + $statusCode = $response ? $response->getStatusCode() : 0; + + return new self($message, $statusCode); + } + + /** + * Creates a ResponseException from response parsing failure. + * + * @since 0.2.0 + * + * @param string $apiName The name of the API/provider. + * @param string $dataType The type of data that failed to parse. + * @param \Throwable|null $previous The previous exception that caused parsing to fail. + * @return self + */ + public static function fromParsingFailure(string $apiName, string $dataType, ?\Throwable $previous = null): self + { + $message = sprintf('Failed to parse %s from %s API response', $dataType, $apiName); + + return new self($message, 0, $previous); + } } diff --git a/src/Providers/Http/HttpTransporter.php b/src/Providers/Http/HttpTransporter.php index 88218a1b..51844bed 100644 --- a/src/Providers/Http/HttpTransporter.php +++ b/src/Providers/Http/HttpTransporter.php @@ -11,9 +11,12 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamFactoryInterface; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; +use WordPress\AiClient\Providers\Http\Exception\NetworkException; +use WordPress\AiClient\Providers\Http\Exception\RequestException; /** * HTTP transporter implementation using HTTPlug. @@ -68,7 +71,26 @@ public function __construct( public function send(Request $request): Response { $psr7Request = $this->convertToPsr7Request($request); - $psr7Response = $this->client->sendRequest($psr7Request); + + try { + $psr7Response = $this->client->sendRequest($psr7Request); + } catch (\Psr\Http\Client\NetworkExceptionInterface $e) { + throw NetworkException::fromPsr18NetworkException($request->getUri(), $e); + } catch (\Psr\Http\Client\ClientExceptionInterface $e) { + // Handle other PSR-18 client exceptions that are not network-related + throw new RuntimeException( + sprintf('HTTP client error occurred while sending request to %s: %s', $request->getUri(), $e->getMessage()), + 0, + $e + ); + } + + // Check for 400 Bad Request responses indicating invalid request data + if ($psr7Response->getStatusCode() === 400) { + $body = (string) $psr7Response->getBody(); + $errorDetail = $body ? substr($body, 0, 200) : 'Invalid request parameters'; + throw RequestException::fromBadRequestToUri($request->getUri(), $errorDetail); + } return $this->convertFromPsr7Response($psr7Response); } diff --git a/src/Results/DTO/GenerativeAiResult.php b/src/Results/DTO/GenerativeAiResult.php index 223ba5fd..6de626f7 100644 --- a/src/Results/DTO/GenerativeAiResult.php +++ b/src/Results/DTO/GenerativeAiResult.php @@ -5,8 +5,8 @@ namespace WordPress\AiClient\Results\DTO; use WordPress\AiClient\Common\AbstractDataTransferObject; -use WordPress\AiClient\Exceptions\InvalidArgumentException; -use WordPress\AiClient\Exceptions\RuntimeException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Providers\DTO\ProviderMetadata; diff --git a/src/Tools/DTO/FunctionResponse.php b/src/Tools/DTO/FunctionResponse.php index c351c02f..76bdf897 100644 --- a/src/Tools/DTO/FunctionResponse.php +++ b/src/Tools/DTO/FunctionResponse.php @@ -5,7 +5,7 @@ namespace WordPress\AiClient\Tools\DTO; use WordPress\AiClient\Common\AbstractDataTransferObject; -use WordPress\AiClient\Exceptions\InvalidArgumentException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; /** * Represents a response to a function call. diff --git a/tests/unit/Exceptions/ExceptionsTest.php b/tests/unit/Exceptions/ExceptionsTest.php index e6364350..c598ef70 100644 --- a/tests/unit/Exceptions/ExceptionsTest.php +++ b/tests/unit/Exceptions/ExceptionsTest.php @@ -5,21 +5,21 @@ namespace WordPress\AiClient\Tests\unit\Exceptions; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Exceptions\AiClientExceptionInterface; -use WordPress\AiClient\Exceptions\InvalidArgumentException; -use WordPress\AiClient\Exceptions\NetworkException; -use WordPress\AiClient\Exceptions\RequestException; -use WordPress\AiClient\Exceptions\RuntimeException; +use WordPress\AiClient\Providers\Http\Exception\NetworkException; +use WordPress\AiClient\Providers\Http\Exception\RequestException; /** * Tests for AI Client exceptions. * * @since 0.2.0 + * @covers \WordPress\AiClient\Common\Exception\InvalidArgumentException + * @covers \WordPress\AiClient\Common\Exception\RuntimeException * @covers \WordPress\AiClient\Exceptions\AiClientExceptionInterface - * @covers \WordPress\AiClient\Exceptions\InvalidArgumentException - * @covers \WordPress\AiClient\Exceptions\RuntimeException - * @covers \WordPress\AiClient\Exceptions\NetworkException - * @covers \WordPress\AiClient\Exceptions\RequestException + * @covers \WordPress\AiClient\Providers\Http\Exception\NetworkException + * @covers \WordPress\AiClient\Providers\Http\Exception\RequestException */ class ExceptionsTest extends TestCase { From 98a84cf656de21984ce0d6cc2c9656e02a1e102e Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 3 Sep 2025 01:01:00 +0300 Subject: [PATCH 04/39] Fix code style violations in exception classes --- .../OpenAi/OpenAiModelMetadataDirectory.php | 3 +-- src/Providers/Http/Exception/NetworkException.php | 10 +++++----- src/Providers/Http/Exception/RequestException.php | 2 +- src/Providers/Http/Exception/ResponseException.php | 6 +++--- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index ab9eca13..bed755b5 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -4,14 +4,13 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; -use RuntimeException; -use WordPress\AiClient\Providers\Http\Exception\ResponseException; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; +use WordPress\AiClient\Providers\Http\Exception\ResponseException; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; diff --git a/src/Providers/Http/Exception/NetworkException.php b/src/Providers/Http/Exception/NetworkException.php index 34a2e0fc..f73c1589 100644 --- a/src/Providers/Http/Exception/NetworkException.php +++ b/src/Providers/Http/Exception/NetworkException.php @@ -29,7 +29,7 @@ class NetworkException extends RuntimeException public static function fromConnectionFailure(string $uri, string $reason = 'Connection failed', ?\Throwable $previous = null): self { $message = sprintf('Network connection failed for %s: %s', $uri, $reason); - + return new self($message, 0, $previous); } @@ -50,7 +50,7 @@ public static function fromTimeout(string $uri, string $timeoutType = 'request', if ($timeoutSeconds !== null) { $message .= sprintf(' (after %d seconds)', $timeoutSeconds); } - + return new self($message, 0, $previous); } @@ -70,7 +70,7 @@ public static function fromPsr18NetworkException(string $uri, \Throwable $networ $uri, $networkException->getMessage() ); - + return new self($message, 0, $networkException); } @@ -86,7 +86,7 @@ public static function fromPsr18NetworkException(string $uri, \Throwable $networ public static function fromDnsFailure(string $hostname, ?\Throwable $previous = null): self { $message = sprintf('Failed to resolve hostname: %s', $hostname); - + return new self($message, 0, $previous); } @@ -103,7 +103,7 @@ public static function fromDnsFailure(string $hostname, ?\Throwable $previous = public static function fromSslError(string $uri, string $sslError, ?\Throwable $previous = null): self { $message = sprintf('SSL/TLS error for %s: %s', $uri, $sslError); - + return new self($message, 0, $previous); } } diff --git a/src/Providers/Http/Exception/RequestException.php b/src/Providers/Http/Exception/RequestException.php index 936e4b00..531441f0 100644 --- a/src/Providers/Http/Exception/RequestException.php +++ b/src/Providers/Http/Exception/RequestException.php @@ -51,7 +51,7 @@ public static function fromBadRequestResponse(string $apiName, Response $respons { $body = $response->getBody(); $errorDetail = $body ? substr($body, 0, 200) : 'Invalid request parameters'; - + $message = sprintf( 'Bad request to %s API (400): %s', $apiName, diff --git a/src/Providers/Http/Exception/ResponseException.php b/src/Providers/Http/Exception/ResponseException.php index 37e686d0..b47f7d99 100644 --- a/src/Providers/Http/Exception/ResponseException.php +++ b/src/Providers/Http/Exception/ResponseException.php @@ -72,9 +72,9 @@ public static function fromUnexpectedStructure(string $apiName, string $expected public static function fromMalformedResponse(string $apiName, string $reason, ?Response $response = null): self { $message = sprintf('Malformed %s API response: %s', $apiName, $reason); - + $statusCode = $response ? $response->getStatusCode() : 0; - + return new self($message, $statusCode); } @@ -91,7 +91,7 @@ public static function fromMalformedResponse(string $apiName, string $reason, ?R public static function fromParsingFailure(string $apiName, string $dataType, ?\Throwable $previous = null): self { $message = sprintf('Failed to parse %s from %s API response', $dataType, $apiName); - + return new self($message, 0, $previous); } } From 28ce137ba35796624923500d0a2049efb9003faf Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 3 Sep 2025 01:05:32 +0300 Subject: [PATCH 05/39] Fix line length violations in exception factory methods --- src/Providers/Http/Exception/NetworkException.php | 13 +++++++++++-- src/Providers/Http/HttpTransporter.php | 6 +++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/Providers/Http/Exception/NetworkException.php b/src/Providers/Http/Exception/NetworkException.php index f73c1589..f4bba161 100644 --- a/src/Providers/Http/Exception/NetworkException.php +++ b/src/Providers/Http/Exception/NetworkException.php @@ -26,7 +26,11 @@ class NetworkException extends RuntimeException * @param \Throwable|null $previous The underlying network exception. * @return self */ - public static function fromConnectionFailure(string $uri, string $reason = 'Connection failed', ?\Throwable $previous = null): self + public static function fromConnectionFailure( + string $uri, + string $reason = 'Connection failed', + ?\Throwable $previous = null + ): self { $message = sprintf('Network connection failed for %s: %s', $uri, $reason); @@ -44,7 +48,12 @@ public static function fromConnectionFailure(string $uri, string $reason = 'Conn * @param \Throwable|null $previous The underlying timeout exception. * @return self */ - public static function fromTimeout(string $uri, string $timeoutType = 'request', ?int $timeoutSeconds = null, ?\Throwable $previous = null): self + public static function fromTimeout( + string $uri, + string $timeoutType = 'request', + ?int $timeoutSeconds = null, + ?\Throwable $previous = null + ): self { $message = sprintf('Network %s timeout for %s', $timeoutType, $uri); if ($timeoutSeconds !== null) { diff --git a/src/Providers/Http/HttpTransporter.php b/src/Providers/Http/HttpTransporter.php index 51844bed..2b887c83 100644 --- a/src/Providers/Http/HttpTransporter.php +++ b/src/Providers/Http/HttpTransporter.php @@ -79,7 +79,11 @@ public function send(Request $request): Response } catch (\Psr\Http\Client\ClientExceptionInterface $e) { // Handle other PSR-18 client exceptions that are not network-related throw new RuntimeException( - sprintf('HTTP client error occurred while sending request to %s: %s', $request->getUri(), $e->getMessage()), + sprintf( + 'HTTP client error occurred while sending request to %s: %s', + $request->getUri(), + $e->getMessage() + ), 0, $e ); From 227498b93e35a33e8154637b1c2ab10a49acb0da Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 3 Sep 2025 01:06:43 +0300 Subject: [PATCH 06/39] Fix function declaration brace placement --- src/Providers/Http/Exception/NetworkException.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Providers/Http/Exception/NetworkException.php b/src/Providers/Http/Exception/NetworkException.php index f4bba161..8823df44 100644 --- a/src/Providers/Http/Exception/NetworkException.php +++ b/src/Providers/Http/Exception/NetworkException.php @@ -30,8 +30,7 @@ public static function fromConnectionFailure( string $uri, string $reason = 'Connection failed', ?\Throwable $previous = null - ): self - { + ): self { $message = sprintf('Network connection failed for %s: %s', $uri, $reason); return new self($message, 0, $previous); @@ -53,8 +52,7 @@ public static function fromTimeout( string $timeoutType = 'request', ?int $timeoutSeconds = null, ?\Throwable $previous = null - ): self - { + ): self { $message = sprintf('Network %s timeout for %s', $timeoutType, $uri); if ($timeoutSeconds !== null) { $message .= sprintf(' (after %d seconds)', $timeoutSeconds); From eb9686d98c02823022c371d23af5176efc3c1a94 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Fri, 5 Sep 2025 18:51:41 +0300 Subject: [PATCH 07/39] Update @since tags from 0.2.0 to n.e.x.t --- src/Common/Exception/InvalidArgumentException.php | 2 +- src/Common/Exception/RuntimeException.php | 2 +- src/Exceptions/AiClientExceptionInterface.php | 2 +- src/Providers/Http/Exception/NetworkException.php | 12 ++++++------ src/Providers/Http/Exception/RequestException.php | 8 ++++---- src/Providers/Http/Exception/ResponseException.php | 8 ++++---- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/Common/Exception/InvalidArgumentException.php b/src/Common/Exception/InvalidArgumentException.php index bb9fb949..731ff776 100644 --- a/src/Common/Exception/InvalidArgumentException.php +++ b/src/Common/Exception/InvalidArgumentException.php @@ -12,7 +12,7 @@ * This extends PHP's built-in InvalidArgumentException while implementing * the AI Client exception interface for consistent catch handling. * - * @since 0.2.0 + * @since n.e.x.t */ class InvalidArgumentException extends \InvalidArgumentException implements AiClientExceptionInterface { diff --git a/src/Common/Exception/RuntimeException.php b/src/Common/Exception/RuntimeException.php index e7685c24..c2048a5b 100644 --- a/src/Common/Exception/RuntimeException.php +++ b/src/Common/Exception/RuntimeException.php @@ -12,7 +12,7 @@ * This extends PHP's built-in RuntimeException while implementing * the AI Client exception interface for consistent catch handling. * - * @since 0.2.0 + * @since n.e.x.t */ class RuntimeException extends \RuntimeException implements AiClientExceptionInterface { diff --git a/src/Exceptions/AiClientExceptionInterface.php b/src/Exceptions/AiClientExceptionInterface.php index 7cc35d9f..3c91e030 100644 --- a/src/Exceptions/AiClientExceptionInterface.php +++ b/src/Exceptions/AiClientExceptionInterface.php @@ -12,7 +12,7 @@ * This interface allows callers to catch all AI Client specific exceptions * with a single catch statement. * - * @since 0.2.0 + * @since n.e.x.t */ interface AiClientExceptionInterface extends Throwable { diff --git a/src/Providers/Http/Exception/NetworkException.php b/src/Providers/Http/Exception/NetworkException.php index 8823df44..0b7b554b 100644 --- a/src/Providers/Http/Exception/NetworkException.php +++ b/src/Providers/Http/Exception/NetworkException.php @@ -12,14 +12,14 @@ * This includes HTTP transport errors, connection failures, * timeouts, and other network-related issues. * - * @since 0.2.0 + * @since n.e.x.t */ class NetworkException extends RuntimeException { /** * Creates a NetworkException for connection failures. * - * @since 0.2.0 + * @since n.e.x.t * * @param string $uri The URI that failed to connect. * @param string $reason The reason for connection failure. @@ -39,7 +39,7 @@ public static function fromConnectionFailure( /** * Creates a NetworkException for timeout errors. * - * @since 0.2.0 + * @since n.e.x.t * * @param string $uri The URI that timed out. * @param string $timeoutType Type of timeout (e.g., 'connection', 'read', 'request'). @@ -64,7 +64,7 @@ public static function fromTimeout( /** * Creates a NetworkException from a PSR-18 network exception. * - * @since 0.2.0 + * @since n.e.x.t * * @param string $uri The URI that was being requested. * @param \Throwable $networkException The PSR-18 network exception. @@ -84,7 +84,7 @@ public static function fromPsr18NetworkException(string $uri, \Throwable $networ /** * Creates a NetworkException for DNS resolution failures. * - * @since 0.2.0 + * @since n.e.x.t * * @param string $hostname The hostname that failed to resolve. * @param \Throwable|null $previous The underlying DNS exception. @@ -100,7 +100,7 @@ public static function fromDnsFailure(string $hostname, ?\Throwable $previous = /** * Creates a NetworkException for SSL/TLS errors. * - * @since 0.2.0 + * @since n.e.x.t * * @param string $uri The URI with SSL/TLS issues. * @param string $sslError Description of the SSL/TLS error. diff --git a/src/Providers/Http/Exception/RequestException.php b/src/Providers/Http/Exception/RequestException.php index 531441f0..38093fcc 100644 --- a/src/Providers/Http/Exception/RequestException.php +++ b/src/Providers/Http/Exception/RequestException.php @@ -14,14 +14,14 @@ * where the API responds with a 400 Bad Request status code indicating * that our code didn't catch an invalid argument but the API did. * - * @since 0.2.0 + * @since n.e.x.t */ class RequestException extends InvalidArgumentException { /** * Creates a RequestException for invalid API parameters. * - * @since 0.2.0 + * @since n.e.x.t * * @param string $apiName The name of the API/provider. * @param string $paramName The parameter that was invalid. @@ -41,7 +41,7 @@ public static function fromInvalidParam(string $apiName, string $paramName, stri /** * Creates a RequestException from a 400 Bad Request API response. * - * @since 0.2.0 + * @since n.e.x.t * * @param string $apiName The name of the API/provider. * @param Response $response The HTTP response containing the error. @@ -64,7 +64,7 @@ public static function fromBadRequestResponse(string $apiName, Response $respons /** * Creates a RequestException from a bad request to a specific URI. * - * @since 0.2.0 + * @since n.e.x.t * * @param string $uri The URI that was requested. * @param string $errorDetail Details about what made the request bad. diff --git a/src/Providers/Http/Exception/ResponseException.php b/src/Providers/Http/Exception/ResponseException.php index b47f7d99..863a26bf 100644 --- a/src/Providers/Http/Exception/ResponseException.php +++ b/src/Providers/Http/Exception/ResponseException.php @@ -21,7 +21,7 @@ class ResponseException extends RuntimeException /** * Creates a ResponseException for missing expected data. * - * @since 0.2.0 + * @since n.e.x.t * * @param string $apiName The name of the API/provider. * @param string $fieldName The field that was expected but missing. @@ -42,7 +42,7 @@ public static function fromMissingData(string $apiName, string $fieldName, strin /** * Creates a ResponseException for unexpected API response structure. * - * @since 0.2.0 + * @since n.e.x.t * * @param string $apiName The name of the API/provider. * @param string $expected What structure was expected. @@ -62,7 +62,7 @@ public static function fromUnexpectedStructure(string $apiName, string $expected /** * Creates a ResponseException for malformed response data. * - * @since 0.2.0 + * @since n.e.x.t * * @param string $apiName The name of the API/provider. * @param string $reason Why the response is considered malformed. @@ -81,7 +81,7 @@ public static function fromMalformedResponse(string $apiName, string $reason, ?R /** * Creates a ResponseException from response parsing failure. * - * @since 0.2.0 + * @since n.e.x.t * * @param string $apiName The name of the API/provider. * @param string $dataType The type of data that failed to parse. From f158e7168141ab5d08ee36ee249db3abe8f75f0d Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Fri, 5 Sep 2025 18:54:46 +0300 Subject: [PATCH 08/39] Update core files to use custom exception classes - ProviderRegistry and PromptBuilder now use project-scoped InvalidArgumentException and RuntimeException for better error categorization and unified exception handling --- src/Builders/PromptBuilder.php | 4 ++-- src/Providers/ProviderRegistry.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 42dbe085..01b23fab 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -4,8 +4,8 @@ namespace WordPress\AiClient\Builders; -use InvalidArgumentException; -use RuntimeException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Messages\DTO\Message; diff --git a/src/Providers/ProviderRegistry.php b/src/Providers/ProviderRegistry.php index 0e0db98c..b622f58b 100644 --- a/src/Providers/ProviderRegistry.php +++ b/src/Providers/ProviderRegistry.php @@ -4,8 +4,8 @@ namespace WordPress\AiClient\Providers; -use InvalidArgumentException; -use RuntimeException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Contracts\ProviderInterface; use WordPress\AiClient\Providers\Contracts\ProviderWithOperationsHandlerInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; From 9eac85b6a034a8d9fe00d30b1fd6f1beaa44d2e6 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Fri, 5 Sep 2025 18:57:53 +0300 Subject: [PATCH 09/39] Update HTTP and File DTOs to use custom exceptions - Files/DTO/File.php and HTTP Request/Response DTOs now use project-scoped exception classes for unified error handling --- src/Files/DTO/File.php | 4 ++-- src/Providers/Http/DTO/Request.php | 2 +- src/Providers/Http/DTO/Response.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Files/DTO/File.php b/src/Files/DTO/File.php index cbf95feb..36bb3e71 100644 --- a/src/Files/DTO/File.php +++ b/src/Files/DTO/File.php @@ -4,8 +4,8 @@ namespace WordPress\AiClient\Files\DTO; -use InvalidArgumentException; -use RuntimeException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\ValueObjects\MimeType; diff --git a/src/Providers/Http/DTO/Request.php b/src/Providers/Http/DTO/Request.php index cbfd03ae..8777bc54 100644 --- a/src/Providers/Http/DTO/Request.php +++ b/src/Providers/Http/DTO/Request.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Providers\Http\DTO; -use InvalidArgumentException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use JsonException; use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Providers\Http\Collections\HeadersCollection; diff --git a/src/Providers/Http/DTO/Response.php b/src/Providers/Http/DTO/Response.php index 3158ce83..0f4e986d 100644 --- a/src/Providers/Http/DTO/Response.php +++ b/src/Providers/Http/DTO/Response.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Providers\Http\DTO; -use InvalidArgumentException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Providers\Http\Collections\HeadersCollection; From 1f2fc0eca6f458720380b8eff10ba6fc0dbe3af2 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Fri, 5 Sep 2025 19:04:15 +0300 Subject: [PATCH 10/39] Implement usage-driven exception hierarchy - Add ResponseException::fromBadResponse() based on real usage in ResponseUtil, remove unused static methods keeping only those actually used in codebase --- .../Http/Exception/NetworkException.php | 74 ------------------ .../Http/Exception/RequestException.php | 41 ---------- .../Http/Exception/ResponseException.php | 76 +++++++++---------- src/Providers/Http/Util/ResponseUtil.php | 31 +------- 4 files changed, 35 insertions(+), 187 deletions(-) diff --git a/src/Providers/Http/Exception/NetworkException.php b/src/Providers/Http/Exception/NetworkException.php index 0b7b554b..34ad8cce 100644 --- a/src/Providers/Http/Exception/NetworkException.php +++ b/src/Providers/Http/Exception/NetworkException.php @@ -16,50 +16,7 @@ */ class NetworkException extends RuntimeException { - /** - * Creates a NetworkException for connection failures. - * - * @since n.e.x.t - * - * @param string $uri The URI that failed to connect. - * @param string $reason The reason for connection failure. - * @param \Throwable|null $previous The underlying network exception. - * @return self - */ - public static function fromConnectionFailure( - string $uri, - string $reason = 'Connection failed', - ?\Throwable $previous = null - ): self { - $message = sprintf('Network connection failed for %s: %s', $uri, $reason); - return new self($message, 0, $previous); - } - - /** - * Creates a NetworkException for timeout errors. - * - * @since n.e.x.t - * - * @param string $uri The URI that timed out. - * @param string $timeoutType Type of timeout (e.g., 'connection', 'read', 'request'). - * @param int|null $timeoutSeconds The timeout duration if known. - * @param \Throwable|null $previous The underlying timeout exception. - * @return self - */ - public static function fromTimeout( - string $uri, - string $timeoutType = 'request', - ?int $timeoutSeconds = null, - ?\Throwable $previous = null - ): self { - $message = sprintf('Network %s timeout for %s', $timeoutType, $uri); - if ($timeoutSeconds !== null) { - $message .= sprintf(' (after %d seconds)', $timeoutSeconds); - } - - return new self($message, 0, $previous); - } /** * Creates a NetworkException from a PSR-18 network exception. @@ -81,36 +38,5 @@ public static function fromPsr18NetworkException(string $uri, \Throwable $networ return new self($message, 0, $networkException); } - /** - * Creates a NetworkException for DNS resolution failures. - * - * @since n.e.x.t - * - * @param string $hostname The hostname that failed to resolve. - * @param \Throwable|null $previous The underlying DNS exception. - * @return self - */ - public static function fromDnsFailure(string $hostname, ?\Throwable $previous = null): self - { - $message = sprintf('Failed to resolve hostname: %s', $hostname); - - return new self($message, 0, $previous); - } - - /** - * Creates a NetworkException for SSL/TLS errors. - * - * @since n.e.x.t - * - * @param string $uri The URI with SSL/TLS issues. - * @param string $sslError Description of the SSL/TLS error. - * @param \Throwable|null $previous The underlying SSL exception. - * @return self - */ - public static function fromSslError(string $uri, string $sslError, ?\Throwable $previous = null): self - { - $message = sprintf('SSL/TLS error for %s: %s', $uri, $sslError); - return new self($message, 0, $previous); - } } diff --git a/src/Providers/Http/Exception/RequestException.php b/src/Providers/Http/Exception/RequestException.php index 38093fcc..5f116189 100644 --- a/src/Providers/Http/Exception/RequestException.php +++ b/src/Providers/Http/Exception/RequestException.php @@ -18,48 +18,7 @@ */ class RequestException extends InvalidArgumentException { - /** - * Creates a RequestException for invalid API parameters. - * - * @since n.e.x.t - * - * @param string $apiName The name of the API/provider. - * @param string $paramName The parameter that was invalid. - * @param string $message Additional error message. - * @return self - */ - public static function fromInvalidParam(string $apiName, string $paramName, string $message = ''): self - { - $errorMessage = sprintf('Invalid parameter "%s" for %s API', $paramName, $apiName); - if ($message !== '') { - $errorMessage .= ': ' . $message; - } - return new self($errorMessage); - } - - /** - * Creates a RequestException from a 400 Bad Request API response. - * - * @since n.e.x.t - * - * @param string $apiName The name of the API/provider. - * @param Response $response The HTTP response containing the error. - * @return self - */ - public static function fromBadRequestResponse(string $apiName, Response $response): self - { - $body = $response->getBody(); - $errorDetail = $body ? substr($body, 0, 200) : 'Invalid request parameters'; - - $message = sprintf( - 'Bad request to %s API (400): %s', - $apiName, - $errorDetail - ); - - return new self($message); - } /** * Creates a RequestException from a bad request to a specific URI. diff --git a/src/Providers/Http/Exception/ResponseException.php b/src/Providers/Http/Exception/ResponseException.php index 863a26bf..fb092d17 100644 --- a/src/Providers/Http/Exception/ResponseException.php +++ b/src/Providers/Http/Exception/ResponseException.php @@ -39,59 +39,51 @@ public static function fromMissingData(string $apiName, string $fieldName, strin return new self($message); } - /** - * Creates a ResponseException for unexpected API response structure. - * - * @since n.e.x.t - * - * @param string $apiName The name of the API/provider. - * @param string $expected What structure was expected. - * @param string $actual What was actually received. - * @return self - */ - public static function fromUnexpectedStructure(string $apiName, string $expected, string $actual = 'unknown'): self - { - return new self(sprintf( - 'Unexpected %s API response structure. Expected: %s, Got: %s', - $apiName, - $expected, - $actual - )); - } /** - * Creates a ResponseException for malformed response data. + * Creates a ResponseException from a bad HTTP response. + * + * This method extracts error details from common API response formats + * and creates an exception with a descriptive message and status code. * * @since n.e.x.t * - * @param string $apiName The name of the API/provider. - * @param string $reason Why the response is considered malformed. - * @param Response|null $response The response object if available. + * @param Response $response The HTTP response that failed. * @return self */ - public static function fromMalformedResponse(string $apiName, string $reason, ?Response $response = null): self + public static function fromBadResponse(Response $response): self { - $message = sprintf('Malformed %s API response: %s', $apiName, $reason); + $errorMessage = sprintf( + 'Bad status code: %d.', + $response->getStatusCode() + ); - $statusCode = $response ? $response->getStatusCode() : 0; + // Handle common error formats in API responses. + $data = $response->getData(); + if ( + is_array($data) && + isset($data['error']) && + is_array($data['error']) && + isset($data['error']['message']) && + is_string($data['error']['message']) + ) { + $errorMessage .= ' ' . $data['error']['message']; + } elseif ( + is_array($data) && + isset($data['error']) && + is_string($data['error']) + ) { + $errorMessage .= ' ' . $data['error']; + } elseif ( + is_array($data) && + isset($data['message']) && + is_string($data['message']) + ) { + $errorMessage .= ' ' . $data['message']; + } - return new self($message, $statusCode); + return new self($errorMessage, $response->getStatusCode()); } - /** - * Creates a ResponseException from response parsing failure. - * - * @since n.e.x.t - * - * @param string $apiName The name of the API/provider. - * @param string $dataType The type of data that failed to parse. - * @param \Throwable|null $previous The previous exception that caused parsing to fail. - * @return self - */ - public static function fromParsingFailure(string $apiName, string $dataType, ?\Throwable $previous = null): self - { - $message = sprintf('Failed to parse %s from %s API response', $dataType, $apiName); - return new self($message, 0, $previous); - } } diff --git a/src/Providers/Http/Util/ResponseUtil.php b/src/Providers/Http/Util/ResponseUtil.php index 4c6f96f9..2aa28d45 100644 --- a/src/Providers/Http/Util/ResponseUtil.php +++ b/src/Providers/Http/Util/ResponseUtil.php @@ -33,35 +33,6 @@ public static function throwIfNotSuccessful(Response $response): void return; } - $errorMessage = sprintf( - 'Bad status code: %d.', - $response->getStatusCode() - ); - - // Handle common error formats in API responses. - $data = $response->getData(); - if ( - is_array($data) && - isset($data['error']) && - is_array($data['error']) && - isset($data['error']['message']) && - is_string($data['error']['message']) - ) { - $errorMessage .= ' ' . $data['error']['message']; - } elseif ( - is_array($data) && - isset($data['error']) && - is_string($data['error']) - ) { - $errorMessage .= ' ' . $data['error']; - } elseif ( - is_array($data) && - isset($data['message']) && - is_string($data['message']) - ) { - $errorMessage .= ' ' . $data['message']; - } - - throw new ResponseException($errorMessage, $response->getStatusCode()); + throw ResponseException::fromBadResponse($response); } } From c1aede54ff3a5dffe44fd1e199192271ddbe0944 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Fri, 5 Sep 2025 19:13:56 +0300 Subject: [PATCH 11/39] Add exception handling requirements to coding standards --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 2d7051df..1f8654e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,15 @@ The production code in `src` follows a structured namespace hierarchy under the All parameters, return values, and properties must use explicit type hints, except in cases where providing the correct type hint would be impossible given limitations of backward compatibility with PHP 7.4. In any case, concrete type annotations using PHPStan should be present. +### Exception handling + +All exceptions must use the project's custom exception classes rather than PHP built-in exceptions. This includes: + +- Use `WordPress\AiClient\Common\Exception\InvalidArgumentException` instead of PHP's `\InvalidArgumentException` +- Use `WordPress\AiClient\Common\Exception\RuntimeException` instead of PHP's `\RuntimeException` +- All custom exceptions implement `WordPress\AiClient\Exceptions\AiClientExceptionInterface` for unified exception handling +- Follow usage-driven design: only implement static factory methods that are actually used in the codebase + ### Naming conventions The following naming conventions must be followed for consistency and autoloading: From f901fce334a1a1f95822b82aca43e0f343d30b14 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Fri, 5 Sep 2025 19:14:10 +0300 Subject: [PATCH 12/39] Implement usage-driven exception hierarchy with custom classes --- src/Files/DTO/File.php | 2 +- src/Providers/Http/DTO/Request.php | 2 +- src/Providers/Http/DTO/Response.php | 2 +- src/Providers/Http/Exception/NetworkException.php | 4 ---- src/Providers/Http/Exception/RequestException.php | 3 --- src/Providers/Http/Exception/ResponseException.php | 2 -- 6 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/Files/DTO/File.php b/src/Files/DTO/File.php index 36bb3e71..339b02d8 100644 --- a/src/Files/DTO/File.php +++ b/src/Files/DTO/File.php @@ -4,9 +4,9 @@ namespace WordPress\AiClient\Files\DTO; +use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; -use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\ValueObjects\MimeType; diff --git a/src/Providers/Http/DTO/Request.php b/src/Providers/Http/DTO/Request.php index 8777bc54..68f873c3 100644 --- a/src/Providers/Http/DTO/Request.php +++ b/src/Providers/Http/DTO/Request.php @@ -4,9 +4,9 @@ namespace WordPress\AiClient\Providers\Http\DTO; -use WordPress\AiClient\Common\Exception\InvalidArgumentException; use JsonException; use WordPress\AiClient\Common\AbstractDataTransferObject; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Providers\Http\Collections\HeadersCollection; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; diff --git a/src/Providers/Http/DTO/Response.php b/src/Providers/Http/DTO/Response.php index 0f4e986d..c51c9de6 100644 --- a/src/Providers/Http/DTO/Response.php +++ b/src/Providers/Http/DTO/Response.php @@ -4,8 +4,8 @@ namespace WordPress\AiClient\Providers\Http\DTO; -use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\AbstractDataTransferObject; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Providers\Http\Collections\HeadersCollection; /** diff --git a/src/Providers/Http/Exception/NetworkException.php b/src/Providers/Http/Exception/NetworkException.php index 34ad8cce..efb1e9f7 100644 --- a/src/Providers/Http/Exception/NetworkException.php +++ b/src/Providers/Http/Exception/NetworkException.php @@ -16,8 +16,6 @@ */ class NetworkException extends RuntimeException { - - /** * Creates a NetworkException from a PSR-18 network exception. * @@ -37,6 +35,4 @@ public static function fromPsr18NetworkException(string $uri, \Throwable $networ return new self($message, 0, $networkException); } - - } diff --git a/src/Providers/Http/Exception/RequestException.php b/src/Providers/Http/Exception/RequestException.php index 5f116189..f1ab42a4 100644 --- a/src/Providers/Http/Exception/RequestException.php +++ b/src/Providers/Http/Exception/RequestException.php @@ -5,7 +5,6 @@ namespace WordPress\AiClient\Providers\Http\Exception; use WordPress\AiClient\Common\Exception\InvalidArgumentException; -use WordPress\AiClient\Providers\Http\DTO\Response; /** * Exception thrown for AI API request errors due to bad request data. @@ -18,8 +17,6 @@ */ class RequestException extends InvalidArgumentException { - - /** * Creates a RequestException from a bad request to a specific URI. * diff --git a/src/Providers/Http/Exception/ResponseException.php b/src/Providers/Http/Exception/ResponseException.php index fb092d17..a9bdf199 100644 --- a/src/Providers/Http/Exception/ResponseException.php +++ b/src/Providers/Http/Exception/ResponseException.php @@ -84,6 +84,4 @@ public static function fromBadResponse(Response $response): self return new self($errorMessage, $response->getStatusCode()); } - - } From 048d889f940a3bd86d169187ed077b6f9e38dd62 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Fri, 5 Sep 2025 19:34:00 +0300 Subject: [PATCH 13/39] Fix File exception handling to use specific MimeType exceptions --- src/Files/DTO/File.php | 7 +------ src/Files/ValueObjects/MimeType.php | 2 +- tests/unit/Files/DTO/FileTest.php | 4 ++-- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/Files/DTO/File.php b/src/Files/DTO/File.php index 339b02d8..4320b668 100644 --- a/src/Files/DTO/File.php +++ b/src/Files/DTO/File.php @@ -375,12 +375,7 @@ private function determineMimeType( $extension = pathinfo($cleanPath, PATHINFO_EXTENSION); if (!empty($extension)) { - try { - return MimeType::fromExtension($extension); - } catch (InvalidArgumentException $e) { - // Extension not recognized, continue to error - unset($e); - } + return MimeType::fromExtension($extension); } } diff --git a/src/Files/ValueObjects/MimeType.php b/src/Files/ValueObjects/MimeType.php index 1bf7b8a8..2e3b8f1f 100644 --- a/src/Files/ValueObjects/MimeType.php +++ b/src/Files/ValueObjects/MimeType.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Files\ValueObjects; -use InvalidArgumentException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; /** * Value object representing a MIME type. diff --git a/tests/unit/Files/DTO/FileTest.php b/tests/unit/Files/DTO/FileTest.php index a249832b..8171a718 100644 --- a/tests/unit/Files/DTO/FileTest.php +++ b/tests/unit/Files/DTO/FileTest.php @@ -4,9 +4,9 @@ namespace WordPress\AiClient\Tests\unit\Files\DTO; -use InvalidArgumentException; use PHPUnit\Framework\TestCase; use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\ValueObjects\MimeType; @@ -280,7 +280,7 @@ public function testDataUriWithoutMimeType(): void public function testUrlWithUnknownExtension(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unable to determine MIME type. Please provide it explicitly.'); + $this->expectExceptionMessage('Unknown file extension: unknown'); new File('https://example.com/file.unknown'); } From 499b5aba58c602c5c57881bd3571ed5080c817b5 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Fri, 5 Sep 2025 19:52:14 +0300 Subject: [PATCH 14/39] Complete exception hierarchy implementation across entire codebase --- src/Common/AbstractDataTransferObject.php | 2 +- src/Common/AbstractEnum.php | 4 ++-- src/Messages/DTO/MessagePart.php | 4 ++-- .../Anthropic/AnthropicModelMetadataDirectory.php | 2 +- src/ProviderImplementations/Anthropic/AnthropicProvider.php | 2 +- .../Google/GoogleModelMetadataDirectory.php | 2 +- src/ProviderImplementations/Google/GoogleProvider.php | 2 +- src/ProviderImplementations/OpenAi/OpenAiProvider.php | 2 +- .../AbstractApiBasedModelMetadataDirectory.php | 2 +- src/Providers/Contracts/ModelMetadataDirectoryInterface.php | 2 +- src/Providers/Contracts/ProviderInterface.php | 2 +- .../Contracts/ProviderOperationsHandlerInterface.php | 2 +- src/Providers/DTO/ProviderModelsMetadata.php | 2 +- src/Providers/Http/Traits/WithHttpTransporterTrait.php | 2 +- src/Providers/Http/Traits/WithRequestAuthenticationTrait.php | 2 +- src/Providers/Models/DTO/ModelConfig.php | 2 +- src/Providers/Models/DTO/ModelMetadata.php | 2 +- src/Providers/Models/DTO/ModelRequirements.php | 2 +- src/Providers/Models/DTO/SupportedOption.php | 2 +- .../AbstractOpenAiCompatibleImageGenerationModel.php | 4 ++-- .../AbstractOpenAiCompatibleTextGenerationModel.php | 4 ++-- src/Results/DTO/Candidate.php | 2 +- src/Tools/DTO/FunctionCall.php | 2 +- 23 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/Common/AbstractDataTransferObject.php b/src/Common/AbstractDataTransferObject.php index 140ae1fa..a0d933ca 100644 --- a/src/Common/AbstractDataTransferObject.php +++ b/src/Common/AbstractDataTransferObject.php @@ -4,11 +4,11 @@ namespace WordPress\AiClient\Common; -use InvalidArgumentException; use JsonSerializable; use stdClass; use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; /** * Abstract base class for all Data Value Objects in the AI Client. diff --git a/src/Common/AbstractEnum.php b/src/Common/AbstractEnum.php index 30403e41..39d92af6 100644 --- a/src/Common/AbstractEnum.php +++ b/src/Common/AbstractEnum.php @@ -5,10 +5,10 @@ namespace WordPress\AiClient\Common; use BadMethodCallException; -use InvalidArgumentException; use JsonSerializable; use ReflectionClass; -use RuntimeException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Common\Exception\RuntimeException; /** * Abstract base class for enum-like behavior in PHP 7.4. diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index 790dc576..d7d2fa06 100644 --- a/src/Messages/DTO/MessagePart.php +++ b/src/Messages/DTO/MessagePart.php @@ -4,9 +4,9 @@ namespace WordPress\AiClient\Messages\DTO; -use InvalidArgumentException; -use RuntimeException; use WordPress\AiClient\Common\AbstractDataTransferObject; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; diff --git a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php index e8f06079..fbd4484f 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php +++ b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\ProviderImplementations\Anthropic; -use RuntimeException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; diff --git a/src/ProviderImplementations/Anthropic/AnthropicProvider.php b/src/ProviderImplementations/Anthropic/AnthropicProvider.php index 74945276..2aa9a8b2 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicProvider.php +++ b/src/ProviderImplementations/Anthropic/AnthropicProvider.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\ProviderImplementations\Anthropic; -use RuntimeException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\AbstractProvider; use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; diff --git a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php index a01237aa..28b3068a 100644 --- a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php +++ b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\ProviderImplementations\Google; -use RuntimeException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; diff --git a/src/ProviderImplementations/Google/GoogleProvider.php b/src/ProviderImplementations/Google/GoogleProvider.php index 1d90413c..bd5b79e3 100644 --- a/src/ProviderImplementations/Google/GoogleProvider.php +++ b/src/ProviderImplementations/Google/GoogleProvider.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\ProviderImplementations\Google; -use RuntimeException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\AbstractProvider; use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; diff --git a/src/ProviderImplementations/OpenAi/OpenAiProvider.php b/src/ProviderImplementations/OpenAi/OpenAiProvider.php index 2c4ef073..b83af862 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiProvider.php +++ b/src/ProviderImplementations/OpenAi/OpenAiProvider.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; -use RuntimeException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\AbstractProvider; use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; diff --git a/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php b/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php index 92f19813..56e1f85a 100644 --- a/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php +++ b/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Providers\ApiBasedImplementation; -use InvalidArgumentException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface; use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface; diff --git a/src/Providers/Contracts/ModelMetadataDirectoryInterface.php b/src/Providers/Contracts/ModelMetadataDirectoryInterface.php index a769338c..3c251f2c 100644 --- a/src/Providers/Contracts/ModelMetadataDirectoryInterface.php +++ b/src/Providers/Contracts/ModelMetadataDirectoryInterface.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Providers\Contracts; -use InvalidArgumentException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** diff --git a/src/Providers/Contracts/ProviderInterface.php b/src/Providers/Contracts/ProviderInterface.php index f59b6367..2a99cbb5 100644 --- a/src/Providers/Contracts/ProviderInterface.php +++ b/src/Providers/Contracts/ProviderInterface.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Providers\Contracts; -use InvalidArgumentException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; diff --git a/src/Providers/Contracts/ProviderOperationsHandlerInterface.php b/src/Providers/Contracts/ProviderOperationsHandlerInterface.php index 27238f29..8d56c942 100644 --- a/src/Providers/Contracts/ProviderOperationsHandlerInterface.php +++ b/src/Providers/Contracts/ProviderOperationsHandlerInterface.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Providers\Contracts; -use InvalidArgumentException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Operations\Contracts\OperationInterface; /** diff --git a/src/Providers/DTO/ProviderModelsMetadata.php b/src/Providers/DTO/ProviderModelsMetadata.php index 4b696cbd..4d67b962 100644 --- a/src/Providers/DTO/ProviderModelsMetadata.php +++ b/src/Providers/DTO/ProviderModelsMetadata.php @@ -4,8 +4,8 @@ namespace WordPress\AiClient\Providers\DTO; -use InvalidArgumentException; use WordPress\AiClient\Common\AbstractDataTransferObject; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** diff --git a/src/Providers/Http/Traits/WithHttpTransporterTrait.php b/src/Providers/Http/Traits/WithHttpTransporterTrait.php index f701e8eb..c08c85a1 100644 --- a/src/Providers/Http/Traits/WithHttpTransporterTrait.php +++ b/src/Providers/Http/Traits/WithHttpTransporterTrait.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Providers\Http\Traits; -use RuntimeException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; /** diff --git a/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php b/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php index 9af25b31..f4cde4b1 100644 --- a/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php +++ b/src/Providers/Http/Traits/WithRequestAuthenticationTrait.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Providers\Http\Traits; -use RuntimeException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; /** diff --git a/src/Providers/Models/DTO/ModelConfig.php b/src/Providers/Models/DTO/ModelConfig.php index 6b27d40c..f925f6d2 100644 --- a/src/Providers/Models/DTO/ModelConfig.php +++ b/src/Providers/Models/DTO/ModelConfig.php @@ -4,8 +4,8 @@ namespace WordPress\AiClient\Providers\Models\DTO; -use InvalidArgumentException; use WordPress\AiClient\Common\AbstractDataTransferObject; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; diff --git a/src/Providers/Models/DTO/ModelMetadata.php b/src/Providers/Models/DTO/ModelMetadata.php index ecdc7bf4..920f6567 100644 --- a/src/Providers/Models/DTO/ModelMetadata.php +++ b/src/Providers/Models/DTO/ModelMetadata.php @@ -4,8 +4,8 @@ namespace WordPress\AiClient\Providers\Models\DTO; -use InvalidArgumentException; use WordPress\AiClient\Common\AbstractDataTransferObject; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; /** diff --git a/src/Providers/Models/DTO/ModelRequirements.php b/src/Providers/Models/DTO/ModelRequirements.php index fc8e37d4..8c1167e5 100644 --- a/src/Providers/Models/DTO/ModelRequirements.php +++ b/src/Providers/Models/DTO/ModelRequirements.php @@ -4,8 +4,8 @@ namespace WordPress\AiClient\Providers\Models\DTO; -use InvalidArgumentException; use WordPress\AiClient\Common\AbstractDataTransferObject; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; /** diff --git a/src/Providers/Models/DTO/SupportedOption.php b/src/Providers/Models/DTO/SupportedOption.php index e1ff0eb0..ac4e1f7e 100644 --- a/src/Providers/Models/DTO/SupportedOption.php +++ b/src/Providers/Models/DTO/SupportedOption.php @@ -4,8 +4,8 @@ namespace WordPress\AiClient\Providers\Models\DTO; -use InvalidArgumentException; use WordPress\AiClient\Common\AbstractDataTransferObject; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Providers\Models\Enums\OptionEnum; /** diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php index 7200fa3f..01bdba9a 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php @@ -4,8 +4,8 @@ namespace WordPress\AiClient\Providers\OpenAiCompatibleImplementation; -use InvalidArgumentException; -use RuntimeException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\DTO\Message; diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index f48fac24..8264867c 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -5,8 +5,8 @@ namespace WordPress\AiClient\Providers\OpenAiCompatibleImplementation; use Generator; -use InvalidArgumentException; -use RuntimeException; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum; diff --git a/src/Results/DTO/Candidate.php b/src/Results/DTO/Candidate.php index 20ee3475..4398656b 100644 --- a/src/Results/DTO/Candidate.php +++ b/src/Results/DTO/Candidate.php @@ -4,8 +4,8 @@ namespace WordPress\AiClient\Results\DTO; -use InvalidArgumentException; use WordPress\AiClient\Common\AbstractDataTransferObject; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Results\Enums\FinishReasonEnum; diff --git a/src/Tools/DTO/FunctionCall.php b/src/Tools/DTO/FunctionCall.php index ffb009ae..b0676e6b 100644 --- a/src/Tools/DTO/FunctionCall.php +++ b/src/Tools/DTO/FunctionCall.php @@ -4,8 +4,8 @@ namespace WordPress\AiClient\Tools\DTO; -use InvalidArgumentException; use WordPress\AiClient\Common\AbstractDataTransferObject; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; /** * Represents a function call request from an AI model. From 469f46e803658735f2050c7191723cae2cbbe165 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 10 Sep 2025 17:56:53 +0300 Subject: [PATCH 15/39] Fix accidentally removed try-catch for unrecognized file extensions --- src/Files/DTO/File.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Files/DTO/File.php b/src/Files/DTO/File.php index 4320b668..339b02d8 100644 --- a/src/Files/DTO/File.php +++ b/src/Files/DTO/File.php @@ -375,7 +375,12 @@ private function determineMimeType( $extension = pathinfo($cleanPath, PATHINFO_EXTENSION); if (!empty($extension)) { - return MimeType::fromExtension($extension); + try { + return MimeType::fromExtension($extension); + } catch (InvalidArgumentException $e) { + // Extension not recognized, continue to error + unset($e); + } } } From c0d3b743d54078707e928c197d189b89ca725b34 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 10 Sep 2025 17:59:03 +0300 Subject: [PATCH 16/39] Update test to expect correct fallback error message for unknown extensions --- tests/unit/Files/DTO/FileTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/Files/DTO/FileTest.php b/tests/unit/Files/DTO/FileTest.php index 8171a718..6f304af1 100644 --- a/tests/unit/Files/DTO/FileTest.php +++ b/tests/unit/Files/DTO/FileTest.php @@ -280,7 +280,7 @@ public function testDataUriWithoutMimeType(): void public function testUrlWithUnknownExtension(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unknown file extension: unknown'); + $this->expectExceptionMessage('Unable to determine MIME type. Please provide it explicitly.'); new File('https://example.com/file.unknown'); } From 04e767fd644b15bb3b9fc43a39af4bfbad74aa0a Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 10 Sep 2025 18:15:21 +0300 Subject: [PATCH 17/39] Implement PSR-18 exception interfaces for HTTP client compliance --- .../Http/Exception/NetworkException.php | 40 +++++++++++++-- .../Http/Exception/RequestException.php | 50 ++++++++++++++++++- .../Http/Exception/ResponseException.php | 3 +- src/Providers/Http/HttpTransporter.php | 4 +- 4 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src/Providers/Http/Exception/NetworkException.php b/src/Providers/Http/Exception/NetworkException.php index efb1e9f7..d9e30a85 100644 --- a/src/Providers/Http/Exception/NetworkException.php +++ b/src/Providers/Http/Exception/NetworkException.php @@ -4,6 +4,8 @@ namespace WordPress\AiClient\Providers\Http\Exception; +use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Http\Message\RequestInterface; use WordPress\AiClient\Common\Exception\RuntimeException; /** @@ -14,25 +16,53 @@ * * @since n.e.x.t */ -class NetworkException extends RuntimeException +class NetworkException extends RuntimeException implements NetworkExceptionInterface { + /** + * The request that failed. + * + * @var RequestInterface|null + */ + private ?RequestInterface $request = null; + /** * Creates a NetworkException from a PSR-18 network exception. * * @since n.e.x.t * - * @param string $uri The URI that was being requested. + * @param RequestInterface $request The request that failed. * @param \Throwable $networkException The PSR-18 network exception. * @return self */ - public static function fromPsr18NetworkException(string $uri, \Throwable $networkException): self + public static function fromPsr18NetworkException(RequestInterface $request, \Throwable $networkException): self { $message = sprintf( 'Network error occurred while sending request to %s: %s', - $uri, + (string) $request->getUri(), $networkException->getMessage() ); - return new self($message, 0, $networkException); + $exception = new self($message, 0, $networkException); + $exception->request = $request; + return $exception; + } + + /** + * Returns the request that failed. + * + * @since n.e.x.t + * + * @return RequestInterface + * @throws \RuntimeException If no request is available (when directly instantiated) + */ + public function getRequest(): RequestInterface + { + if ($this->request === null) { + throw new \RuntimeException( + 'Request object not available. This exception was directly instantiated. Use fromPsr18NetworkException() factory method for PSR-18 compliance.' + ); + } + + return $this->request; } } diff --git a/src/Providers/Http/Exception/RequestException.php b/src/Providers/Http/Exception/RequestException.php index f1ab42a4..a6cc2f44 100644 --- a/src/Providers/Http/Exception/RequestException.php +++ b/src/Providers/Http/Exception/RequestException.php @@ -4,6 +4,8 @@ namespace WordPress\AiClient\Providers\Http\Exception; +use Psr\Http\Client\RequestExceptionInterface; +use Psr\Http\Message\RequestInterface; use WordPress\AiClient\Common\Exception\InvalidArgumentException; /** @@ -15,8 +17,33 @@ * * @since n.e.x.t */ -class RequestException extends InvalidArgumentException +class RequestException extends InvalidArgumentException implements RequestExceptionInterface { + /** + * The request that failed. + * + * @var RequestInterface|null + */ + private ?RequestInterface $request = null; + + /** + * Creates a RequestException from a bad request. + * + * @since n.e.x.t + * + * @param RequestInterface $request The request that failed. + * @param string $errorDetail Details about what made the request bad. + * @return self + */ + public static function fromBadRequest(RequestInterface $request, string $errorDetail = 'Invalid request parameters'): self + { + $message = sprintf('Bad request to %s (400): %s', (string) $request->getUri(), $errorDetail); + + $exception = new self($message); + $exception->request = $request; + return $exception; + } + /** * Creates a RequestException from a bad request to a specific URI. * @@ -25,9 +52,30 @@ class RequestException extends InvalidArgumentException * @param string $uri The URI that was requested. * @param string $errorDetail Details about what made the request bad. * @return self + * + * @deprecated Use fromBadRequest() with RequestInterface for PSR-18 compliance */ public static function fromBadRequestToUri(string $uri, string $errorDetail = 'Invalid request parameters'): self { return new self(sprintf('Bad request to %s (400): %s', $uri, $errorDetail)); } + + /** + * Returns the request that failed. + * + * @since n.e.x.t + * + * @return RequestInterface + * @throws \RuntimeException If no request is available (when using deprecated fromBadRequestToUri) + */ + public function getRequest(): RequestInterface + { + if ($this->request === null) { + throw new \RuntimeException( + 'Request object not available. This exception was created using the deprecated fromBadRequestToUri() method. Use fromBadRequest() instead for PSR-18 compliance.' + ); + } + + return $this->request; + } } diff --git a/src/Providers/Http/Exception/ResponseException.php b/src/Providers/Http/Exception/ResponseException.php index a9bdf199..2ec6b73f 100644 --- a/src/Providers/Http/Exception/ResponseException.php +++ b/src/Providers/Http/Exception/ResponseException.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\Providers\Http\Exception; +use Psr\Http\Client\ClientExceptionInterface; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Http\DTO\Response; @@ -16,7 +17,7 @@ * * @since 0.1.0 */ -class ResponseException extends RuntimeException +class ResponseException extends RuntimeException implements ClientExceptionInterface { /** * Creates a ResponseException for missing expected data. diff --git a/src/Providers/Http/HttpTransporter.php b/src/Providers/Http/HttpTransporter.php index 2b887c83..9dd5441d 100644 --- a/src/Providers/Http/HttpTransporter.php +++ b/src/Providers/Http/HttpTransporter.php @@ -75,7 +75,7 @@ public function send(Request $request): Response try { $psr7Response = $this->client->sendRequest($psr7Request); } catch (\Psr\Http\Client\NetworkExceptionInterface $e) { - throw NetworkException::fromPsr18NetworkException($request->getUri(), $e); + throw NetworkException::fromPsr18NetworkException($psr7Request, $e); } catch (\Psr\Http\Client\ClientExceptionInterface $e) { // Handle other PSR-18 client exceptions that are not network-related throw new RuntimeException( @@ -93,7 +93,7 @@ public function send(Request $request): Response if ($psr7Response->getStatusCode() === 400) { $body = (string) $psr7Response->getBody(); $errorDetail = $body ? substr($body, 0, 200) : 'Invalid request parameters'; - throw RequestException::fromBadRequestToUri($request->getUri(), $errorDetail); + throw RequestException::fromBadRequest($psr7Request, $errorDetail); } return $this->convertFromPsr7Response($psr7Response); From d2f5d62cfde4faad4900dd557c15615c9676c908 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 10 Sep 2025 18:17:45 +0300 Subject: [PATCH 18/39] Generalize exception handling guidelines in AGENTS.md --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1f8654e8..6081c12e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,8 +87,8 @@ All parameters, return values, and properties must use explicit type hints, exce All exceptions must use the project's custom exception classes rather than PHP built-in exceptions. This includes: -- Use `WordPress\AiClient\Common\Exception\InvalidArgumentException` instead of PHP's `\InvalidArgumentException` -- Use `WordPress\AiClient\Common\Exception\RuntimeException` instead of PHP's `\RuntimeException` +- Use the custom primitive exceptions in `WordPress\AiClient\Common\Exception\` instead of the PHP primitive exceptions +- If a PHP primitive exception is needed that doesn't have a custom exception counterpart, then create one that extends the primitive and implements AiClientExceptionInterface - All custom exceptions implement `WordPress\AiClient\Exceptions\AiClientExceptionInterface` for unified exception handling - Follow usage-driven design: only implement static factory methods that are actually used in the codebase From 9330eddbfd3b6f583e77a9dc600412cca679631b Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 10 Sep 2025 18:20:40 +0300 Subject: [PATCH 19/39] Fix code style violations in HTTP exception classes --- src/Providers/Http/Exception/NetworkException.php | 5 +++-- src/Providers/Http/Exception/RequestException.php | 15 +++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Providers/Http/Exception/NetworkException.php b/src/Providers/Http/Exception/NetworkException.php index d9e30a85..076cca43 100644 --- a/src/Providers/Http/Exception/NetworkException.php +++ b/src/Providers/Http/Exception/NetworkException.php @@ -59,10 +59,11 @@ public function getRequest(): RequestInterface { if ($this->request === null) { throw new \RuntimeException( - 'Request object not available. This exception was directly instantiated. Use fromPsr18NetworkException() factory method for PSR-18 compliance.' + 'Request object not available. This exception was directly instantiated. ' . + 'Use fromPsr18NetworkException() factory method for PSR-18 compliance.' ); } - + return $this->request; } } diff --git a/src/Providers/Http/Exception/RequestException.php b/src/Providers/Http/Exception/RequestException.php index a6cc2f44..70420a35 100644 --- a/src/Providers/Http/Exception/RequestException.php +++ b/src/Providers/Http/Exception/RequestException.php @@ -35,10 +35,12 @@ class RequestException extends InvalidArgumentException implements RequestExcept * @param string $errorDetail Details about what made the request bad. * @return self */ - public static function fromBadRequest(RequestInterface $request, string $errorDetail = 'Invalid request parameters'): self - { + public static function fromBadRequest( + RequestInterface $request, + string $errorDetail = 'Invalid request parameters' + ): self { $message = sprintf('Bad request to %s (400): %s', (string) $request->getUri(), $errorDetail); - + $exception = new self($message); $exception->request = $request; return $exception; @@ -52,7 +54,7 @@ public static function fromBadRequest(RequestInterface $request, string $errorDe * @param string $uri The URI that was requested. * @param string $errorDetail Details about what made the request bad. * @return self - * + * * @deprecated Use fromBadRequest() with RequestInterface for PSR-18 compliance */ public static function fromBadRequestToUri(string $uri, string $errorDetail = 'Invalid request parameters'): self @@ -72,10 +74,11 @@ public function getRequest(): RequestInterface { if ($this->request === null) { throw new \RuntimeException( - 'Request object not available. This exception was created using the deprecated fromBadRequestToUri() method. Use fromBadRequest() instead for PSR-18 compliance.' + 'Request object not available. This exception was created using the deprecated ' . + 'fromBadRequestToUri() method. Use fromBadRequest() instead for PSR-18 compliance.' ); } - + return $this->request; } } From 5fe7df972c28e732049fc6238a1c4f966dd4b31f Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 11 Sep 2025 17:31:58 +0300 Subject: [PATCH 20/39] Restore missing ModelRequirements methods from trunk --- .../Models/DTO/ModelRequirements.php | 291 +++++++++++++++++- 1 file changed, 289 insertions(+), 2 deletions(-) diff --git a/src/Providers/Models/DTO/ModelRequirements.php b/src/Providers/Models/DTO/ModelRequirements.php index 8c1167e5..c64b47c4 100644 --- a/src/Providers/Models/DTO/ModelRequirements.php +++ b/src/Providers/Models/DTO/ModelRequirements.php @@ -4,9 +4,12 @@ namespace WordPress\AiClient\Providers\Models\DTO; +use InvalidArgumentException; use WordPress\AiClient\Common\AbstractDataTransferObject; -use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Messages\DTO\Message; +use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; +use WordPress\AiClient\Providers\Models\Enums\OptionEnum; /** * Represents requirements that implementing code has for AI model selection. @@ -88,6 +91,290 @@ public function getRequiredOptions(): array return $this->requiredOptions; } + /** + * Checks whether the given model metadata meets these requirements. + * + * @since n.e.x.t + * + * @param ModelMetadata $metadata The model metadata to check against. + * @return bool True if the model meets all requirements, false otherwise. + */ + public function areMetBy(ModelMetadata $metadata): bool + { + // Create lookup maps for better performance (instead of nested foreach loops) + $capabilitiesMap = []; + foreach ($metadata->getSupportedCapabilities() as $capability) { + $capabilitiesMap[$capability->value] = $capability; + } + + $optionsMap = []; + foreach ($metadata->getSupportedOptions() as $option) { + $optionsMap[$option->getName()->value] = $option; + } + + // Check if all required capabilities are supported using map lookup + foreach ($this->requiredCapabilities as $requiredCapability) { + if (!isset($capabilitiesMap[$requiredCapability->value])) { + return false; + } + } + + // Check if all required options are supported with the specified values + foreach ($this->requiredOptions as $requiredOption) { + // Use map lookup instead of linear search + if (!isset($optionsMap[$requiredOption->getName()->value])) { + return false; + } + + $supportedOption = $optionsMap[$requiredOption->getName()->value]; + + // Check if the required value is supported by this option + if (!$supportedOption->isSupportedValue($requiredOption->getValue())) { + return false; + } + } + + return true; + } + + /** + * Creates ModelRequirements from prompt data and model configuration. + * + * @since n.e.x.t + * + * @param CapabilityEnum $capability The capability the model must support. + * @param list $messages The messages in the conversation. + * @param ModelConfig $modelConfig The model configuration. + * @return self The created requirements. + */ + public static function fromPromptData(CapabilityEnum $capability, array $messages, ModelConfig $modelConfig): self + { + // Start with base capability + $capabilities = [$capability]; + $inputModalities = []; + + // Check if we have chat history (multiple messages) + if (count($messages) > 1) { + $capabilities[] = CapabilityEnum::chatHistory(); + } + + // Analyze all messages to determine required input modalities + $hasFunctionMessageParts = false; + foreach ($messages as $message) { + foreach ($message->getParts() as $part) { + // Check for text input + if ($part->getType()->isText()) { + $inputModalities[] = ModalityEnum::text(); + } + + // Check for file inputs + if ($part->getType()->isFile()) { + $file = $part->getFile(); + + if ($file !== null) { + if ($file->isImage()) { + $inputModalities[] = ModalityEnum::image(); + } elseif ($file->isAudio()) { + $inputModalities[] = ModalityEnum::audio(); + } elseif ($file->isVideo()) { + $inputModalities[] = ModalityEnum::video(); + } elseif ($file->isDocument() || $file->isText()) { + $inputModalities[] = ModalityEnum::document(); + } + } + } + + // Check for function calls/responses (these might require special capabilities) + if ($part->getType()->isFunctionCall() || $part->getType()->isFunctionResponse()) { + $hasFunctionMessageParts = true; + } + } + } + + // Convert ModelConfig to RequiredOptions + $requiredOptions = self::toRequiredOptions($modelConfig); + + // Add additional options based on message analysis + if ($hasFunctionMessageParts) { + $requiredOptions = self::includeInRequiredOptions( + $requiredOptions, + new RequiredOption(OptionEnum::functionDeclarations(), true) + ); + } + + // Add input modalities if we have any inputs + if (!empty($inputModalities)) { + // Remove duplicates + $inputModalities = array_unique($inputModalities, SORT_REGULAR); + $requiredOptions = self::includeInRequiredOptions( + $requiredOptions, + new RequiredOption(OptionEnum::inputModalities(), array_values($inputModalities)) + ); + } + + // Step 6: Return new ModelRequirements + return new self($capabilities, $requiredOptions); + } + + /** + * Converts ModelConfig to an array of RequiredOptions. + * + * @since n.e.x.t + * + * @param ModelConfig $modelConfig The model configuration. + * @return list The required options. + */ + private static function toRequiredOptions(ModelConfig $modelConfig): array + { + $requiredOptions = []; + + // Map properties that have corresponding OptionEnum values + if ($modelConfig->getOutputModalities() !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::outputModalities(), + $modelConfig->getOutputModalities() + ); + } + + if ($modelConfig->getSystemInstruction() !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::systemInstruction(), + $modelConfig->getSystemInstruction() + ); + } + + if ($modelConfig->getCandidateCount() !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::candidateCount(), + $modelConfig->getCandidateCount() + ); + } + + if ($modelConfig->getMaxTokens() !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::maxTokens(), + $modelConfig->getMaxTokens() + ); + } + + if ($modelConfig->getTemperature() !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::temperature(), + $modelConfig->getTemperature() + ); + } + + if ($modelConfig->getTopP() !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::topP(), + $modelConfig->getTopP() + ); + } + + if ($modelConfig->getTopK() !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::topK(), + $modelConfig->getTopK() + ); + } + + if ($modelConfig->getOutputMimeType() !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::outputMimeType(), + $modelConfig->getOutputMimeType() + ); + } + + if ($modelConfig->getOutputSchema() !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::outputSchema(), + $modelConfig->getOutputSchema() + ); + } + + // Handle properties without OptionEnum values as custom options + if ($modelConfig->getStopSequences() !== null) { + $requiredOptions[] = new RequiredOption(OptionEnum::stopSequences(), $modelConfig->getStopSequences()); + } + + if ($modelConfig->getPresencePenalty() !== null) { + $requiredOptions[] = new RequiredOption(OptionEnum::presencePenalty(), $modelConfig->getPresencePenalty()); + } + + if ($modelConfig->getFrequencyPenalty() !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::frequencyPenalty(), + $modelConfig->getFrequencyPenalty() + ); + } + + if ($modelConfig->getLogprobs() !== null) { + $requiredOptions[] = new RequiredOption(OptionEnum::logprobs(), $modelConfig->getLogprobs()); + } + + if ($modelConfig->getTopLogprobs() !== null) { + $requiredOptions[] = new RequiredOption(OptionEnum::topLogprobs(), $modelConfig->getTopLogprobs()); + } + + if ($modelConfig->getFunctionDeclarations() !== null) { + $requiredOptions[] = new RequiredOption(OptionEnum::functionDeclarations(), true); + } + + if ($modelConfig->getWebSearch() !== null) { + $requiredOptions[] = new RequiredOption(OptionEnum::webSearch(), true); + } + + if ($modelConfig->getOutputFileType() !== null) { + $requiredOptions[] = new RequiredOption(OptionEnum::outputFileType(), $modelConfig->getOutputFileType()); + } + + if ($modelConfig->getOutputMediaOrientation() !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::outputMediaOrientation(), + $modelConfig->getOutputMediaOrientation() + ); + } + + if ($modelConfig->getOutputMediaAspectRatio() !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::outputMediaAspectRatio(), + $modelConfig->getOutputMediaAspectRatio() + ); + } + + // Add custom options as individual RequiredOptions + foreach ($modelConfig->getCustomOptions() as $key => $value) { + $requiredOptions[] = new RequiredOption(OptionEnum::customOptions(), [$key => $value]); + } + + return $requiredOptions; + } + + /** + * Includes a RequiredOption in the array, ensuring no duplicates based on option name. + * + * @since n.e.x.t + * + * @param list $requiredOptions The existing required options. + * @param RequiredOption $newOption The new option to include. + * @return list The updated required options array. + */ + private static function includeInRequiredOptions(array $requiredOptions, RequiredOption $newOption): array + { + // Check if we already have this option name + foreach ($requiredOptions as $index => $existingOption) { + if ($existingOption->getName()->equals($newOption->getName())) { + // Replace existing option with new one + $requiredOptions[$index] = $newOption; + return $requiredOptions; + } + } + + // Option not found, add it + $requiredOptions[] = $newOption; + return $requiredOptions; + } + /** * {@inheritDoc} * @@ -157,4 +444,4 @@ public static function fromArray(array $array): self ) ); } -} +} \ No newline at end of file From 23ceb284a4a22ffa6e04cde4dc046dc4aae9b5b3 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 11 Sep 2025 17:35:15 +0300 Subject: [PATCH 21/39] Fix code style: add missing newline at end of file --- src/Providers/Models/DTO/ModelRequirements.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Providers/Models/DTO/ModelRequirements.php b/src/Providers/Models/DTO/ModelRequirements.php index c64b47c4..f798e282 100644 --- a/src/Providers/Models/DTO/ModelRequirements.php +++ b/src/Providers/Models/DTO/ModelRequirements.php @@ -444,4 +444,4 @@ public static function fromArray(array $array): self ) ); } -} \ No newline at end of file +} From a6bbaf2cafd54b96ab36579d32a57dbafb155c64 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 11 Sep 2025 17:37:22 +0300 Subject: [PATCH 22/39] Implement exception hierarchy improvements --- .../Contracts}/AiClientExceptionInterface.php | 2 +- src/Common/Exception/InvalidArgumentException.php | 2 +- src/Common/Exception/RuntimeException.php | 2 +- tests/unit/Exceptions/ExceptionsTest.php | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) rename src/{Exceptions => Common/Contracts}/AiClientExceptionInterface.php (86%) diff --git a/src/Exceptions/AiClientExceptionInterface.php b/src/Common/Contracts/AiClientExceptionInterface.php similarity index 86% rename from src/Exceptions/AiClientExceptionInterface.php rename to src/Common/Contracts/AiClientExceptionInterface.php index 3c91e030..50322a52 100644 --- a/src/Exceptions/AiClientExceptionInterface.php +++ b/src/Common/Contracts/AiClientExceptionInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Exceptions; +namespace WordPress\AiClient\Common\Contracts; use Throwable; diff --git a/src/Common/Exception/InvalidArgumentException.php b/src/Common/Exception/InvalidArgumentException.php index 731ff776..15aa98cf 100644 --- a/src/Common/Exception/InvalidArgumentException.php +++ b/src/Common/Exception/InvalidArgumentException.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Common\Exception; -use WordPress\AiClient\Exceptions\AiClientExceptionInterface; +use WordPress\AiClient\Common\Contracts\AiClientExceptionInterface; /** * Exception thrown when an invalid argument is provided. diff --git a/src/Common/Exception/RuntimeException.php b/src/Common/Exception/RuntimeException.php index c2048a5b..db193101 100644 --- a/src/Common/Exception/RuntimeException.php +++ b/src/Common/Exception/RuntimeException.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Common\Exception; -use WordPress\AiClient\Exceptions\AiClientExceptionInterface; +use WordPress\AiClient\Common\Contracts\AiClientExceptionInterface; /** * Exception thrown for runtime errors. diff --git a/tests/unit/Exceptions/ExceptionsTest.php b/tests/unit/Exceptions/ExceptionsTest.php index c598ef70..a7b5da06 100644 --- a/tests/unit/Exceptions/ExceptionsTest.php +++ b/tests/unit/Exceptions/ExceptionsTest.php @@ -5,9 +5,9 @@ namespace WordPress\AiClient\Tests\unit\Exceptions; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Contracts\AiClientExceptionInterface; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; -use WordPress\AiClient\Exceptions\AiClientExceptionInterface; use WordPress\AiClient\Providers\Http\Exception\NetworkException; use WordPress\AiClient\Providers\Http\Exception\RequestException; @@ -17,7 +17,7 @@ * @since 0.2.0 * @covers \WordPress\AiClient\Common\Exception\InvalidArgumentException * @covers \WordPress\AiClient\Common\Exception\RuntimeException - * @covers \WordPress\AiClient\Exceptions\AiClientExceptionInterface + * @covers \WordPress\AiClient\Common\Contracts\AiClientExceptionInterface * @covers \WordPress\AiClient\Providers\Http\Exception\NetworkException * @covers \WordPress\AiClient\Providers\Http\Exception\RequestException */ From 7c742aad6d887823ec8e7dc11814609e9eb44cc5 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 11 Sep 2025 17:44:03 +0300 Subject: [PATCH 23/39] Update Anthropic and Google ModelMetadataDirectory to use ResponseException --- .../Anthropic/AnthropicModelMetadataDirectory.php | 6 ++---- .../Google/GoogleModelMetadataDirectory.php | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php index fbd4484f..1a01b4fe 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php +++ b/src/ProviderImplementations/Anthropic/AnthropicModelMetadataDirectory.php @@ -4,13 +4,13 @@ namespace WordPress\AiClient\ProviderImplementations\Anthropic; -use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; +use WordPress\AiClient\Providers\Http\Exception\ResponseException; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; @@ -71,9 +71,7 @@ protected function parseResponseToModelMetadataList(Response $response): array /** @var ModelsResponseData $responseData */ $responseData = $response->getData(); if (!isset($responseData['data']) || !$responseData['data']) { - throw new RuntimeException( - 'Unexpected API response: Missing the data key.' - ); + throw ResponseException::fromMissingData('Anthropic', 'data'); } // Unfortunately, the Anthropic API does not return model capabilities, so we have to hardcode them here. diff --git a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php index 28b3068a..1b3de9be 100644 --- a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php +++ b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php @@ -4,7 +4,6 @@ namespace WordPress\AiClient\ProviderImplementations\Google; -use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; @@ -13,6 +12,7 @@ use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; +use WordPress\AiClient\Providers\Http\Exception\ResponseException; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; @@ -87,9 +87,7 @@ protected function parseResponseToModelMetadataList(Response $response): array /** @var ModelsResponseData $responseData */ $responseData = $response->getData(); if (!isset($responseData['models']) || !$responseData['models']) { - throw new RuntimeException( - 'Unexpected API response: Missing the models key.' - ); + throw ResponseException::fromMissingData('Google', 'models'); } $geminiCapabilities = [ From 4a69461befbae0fdbdd457849b0a17f330d1bdd7 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 11 Sep 2025 17:54:56 +0300 Subject: [PATCH 24/39] Implement ClientException for 4xx responses in ResponseUtil --- .../Http/Exception/ClientException.php | 30 +++++++++++++++++++ src/Providers/Http/HttpTransporter.php | 9 ------ src/Providers/Http/Util/ResponseUtil.php | 8 +++++ 3 files changed, 38 insertions(+), 9 deletions(-) create mode 100644 src/Providers/Http/Exception/ClientException.php diff --git a/src/Providers/Http/Exception/ClientException.php b/src/Providers/Http/Exception/ClientException.php new file mode 100644 index 00000000..7402a809 --- /dev/null +++ b/src/Providers/Http/Exception/ClientException.php @@ -0,0 +1,30 @@ +getStatusCode() === 400) { - $body = (string) $psr7Response->getBody(); - $errorDetail = $body ? substr($body, 0, 200) : 'Invalid request parameters'; - throw RequestException::fromBadRequest($psr7Request, $errorDetail); - } - return $this->convertFromPsr7Response($psr7Response); } diff --git a/src/Providers/Http/Util/ResponseUtil.php b/src/Providers/Http/Util/ResponseUtil.php index 2aa28d45..60109199 100644 --- a/src/Providers/Http/Util/ResponseUtil.php +++ b/src/Providers/Http/Util/ResponseUtil.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Providers\Http\Util; use WordPress\AiClient\Providers\Http\DTO\Response; +use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Http\Exception\ResponseException; /** @@ -33,6 +34,13 @@ public static function throwIfNotSuccessful(Response $response): void return; } + // Check for 400 Bad Request responses indicating invalid request data + if ($response->getStatusCode() === 400) { + $body = (string) $response->getBody(); + $errorDetail = $body ? substr($body, 0, 200) : 'Invalid request parameters'; + throw ClientException::fromBadRequestResponse($errorDetail); + } + throw ResponseException::fromBadResponse($response); } } From 8ab52c3328b20d44945286116f66fb3e49d0f342 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 11 Sep 2025 17:58:52 +0300 Subject: [PATCH 25/39] Implement complete exception hierarchy with ClientException and ServerException --- .../Http/Exception/ClientException.php | 49 ++++++++++++++- .../Http/Exception/ServerException.php | 63 +++++++++++++++++++ src/Providers/Http/Util/ResponseUtil.php | 38 ++++++++--- 3 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 src/Providers/Http/Exception/ServerException.php diff --git a/src/Providers/Http/Exception/ClientException.php b/src/Providers/Http/Exception/ClientException.php index 7402a809..6d2da28f 100644 --- a/src/Providers/Http/Exception/ClientException.php +++ b/src/Providers/Http/Exception/ClientException.php @@ -4,6 +4,8 @@ namespace WordPress\AiClient\Providers\Http\Exception; +use WordPress\AiClient\Providers\Http\DTO\Response; + /** * Exception thrown for 4xx HTTP client errors. * @@ -25,6 +27,51 @@ class ClientException extends RequestException public static function fromBadRequestResponse(string $errorDetail = 'Invalid request parameters'): self { $message = sprintf('Bad request (400): %s', $errorDetail); - return new self($message); + return new self($message, 400); + } + + /** + * Creates a ClientException from a client error response (4xx). + * + * This method extracts error details from common API response formats + * and creates an exception with a descriptive message and status code. + * + * @since n.e.x.t + * + * @param Response $response The HTTP response that failed. + * @return self + */ + public static function fromClientError(Response $response): self + { + $errorMessage = sprintf( + 'Client error (%d): Request was rejected due to client-side issue', + $response->getStatusCode() + ); + + // Handle common error formats in API responses + $data = $response->getData(); + if ( + is_array($data) && + isset($data['error']) && + is_array($data['error']) && + isset($data['error']['message']) && + is_string($data['error']['message']) + ) { + $errorMessage .= ' - ' . $data['error']['message']; + } elseif ( + is_array($data) && + isset($data['error']) && + is_string($data['error']) + ) { + $errorMessage .= ' - ' . $data['error']; + } elseif ( + is_array($data) && + isset($data['message']) && + is_string($data['message']) + ) { + $errorMessage .= ' - ' . $data['message']; + } + + return new self($errorMessage, $response->getStatusCode()); } } diff --git a/src/Providers/Http/Exception/ServerException.php b/src/Providers/Http/Exception/ServerException.php new file mode 100644 index 00000000..0fd1c507 --- /dev/null +++ b/src/Providers/Http/Exception/ServerException.php @@ -0,0 +1,63 @@ +getStatusCode() + ); + + // Handle common error formats in API responses + $data = $response->getData(); + if ( + is_array($data) && + isset($data['error']) && + is_array($data['error']) && + isset($data['error']['message']) && + is_string($data['error']['message']) + ) { + $errorMessage .= ' - ' . $data['error']['message']; + } elseif ( + is_array($data) && + isset($data['error']) && + is_string($data['error']) + ) { + $errorMessage .= ' - ' . $data['error']; + } elseif ( + is_array($data) && + isset($data['message']) && + is_string($data['message']) + ) { + $errorMessage .= ' - ' . $data['message']; + } + + return new self($errorMessage, $response->getStatusCode()); + } +} diff --git a/src/Providers/Http/Util/ResponseUtil.php b/src/Providers/Http/Util/ResponseUtil.php index 60109199..5aee7d57 100644 --- a/src/Providers/Http/Util/ResponseUtil.php +++ b/src/Providers/Http/Util/ResponseUtil.php @@ -7,6 +7,7 @@ use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Http\Exception\ResponseException; +use WordPress\AiClient\Providers\Http\Exception\ServerException; /** * Class with static utility methods to process HTTP responses. @@ -16,17 +17,20 @@ class ResponseUtil { /** - * Throws a response exception if the given response is not successful. + * Throws an appropriate exception if the given response is not successful. * * This method checks the HTTP status code of the response and throws - * a ResponseException if the status code indicates an error (i.e., not in the - * 2xx range). It also attempts to extract a more detailed error message from - * the response body if available. + * the appropriate exception type based on the status code range: + * - 4xx: ClientException (client errors) + * - 5xx: ServerException (server errors) + * - Other unsuccessful responses: ResponseException (malformed responses) * * @since 0.1.0 * * @param Response $response The HTTP response to check. - * @throws ResponseException If the response is not successful. + * @throws ClientException If the response indicates a client error (4xx). + * @throws ServerException If the response indicates a server error (5xx). + * @throws ResponseException If the response format is unexpected. */ public static function throwIfNotSuccessful(Response $response): void { @@ -34,13 +38,27 @@ public static function throwIfNotSuccessful(Response $response): void return; } - // Check for 400 Bad Request responses indicating invalid request data - if ($response->getStatusCode() === 400) { - $body = (string) $response->getBody(); - $errorDetail = $body ? substr($body, 0, 200) : 'Invalid request parameters'; - throw ClientException::fromBadRequestResponse($errorDetail); + $statusCode = $response->getStatusCode(); + + // 4xx Client Errors + if ($statusCode >= 400 && $statusCode < 500) { + // Special handling for 400 Bad Request + if ($statusCode === 400) { + $body = (string) $response->getBody(); + $errorDetail = $body ? substr($body, 0, 200) : 'Invalid request parameters'; + throw ClientException::fromBadRequestResponse($errorDetail); + } + // General 4xx client errors + throw ClientException::fromClientError($response); + } + + // 5xx Server Errors + if ($statusCode >= 500 && $statusCode < 600) { + throw ServerException::fromServerError($response); } + // Other unsuccessful responses (3xx redirects, etc.) - these should be rare + // as most HTTP clients handle redirects automatically throw ResponseException::fromBadResponse($response); } } From beb17e1e88f4e81f803479a8691916bfc73de415 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 11 Sep 2025 18:11:23 +0300 Subject: [PATCH 26/39] Update tests to expect new exception hierarchy --- .../Http/Exception/NetworkException.php | 3 +- .../Http/Exception/RedirectException.php | 61 +++++++++++++ src/Providers/Http/Util/ResponseUtil.php | 11 ++- .../Providers/Http/Util/ResponseUtilTest.php | 87 +++++++++++++++---- ...enAiCompatibleImageGenerationModelTest.php | 9 +- ...AiCompatibleModelMetadataDirectoryTest.php | 5 +- ...penAiCompatibleTextGenerationModelTest.php | 5 +- 7 files changed, 152 insertions(+), 29 deletions(-) create mode 100644 src/Providers/Http/Exception/RedirectException.php diff --git a/src/Providers/Http/Exception/NetworkException.php b/src/Providers/Http/Exception/NetworkException.php index 076cca43..0109f544 100644 --- a/src/Providers/Http/Exception/NetworkException.php +++ b/src/Providers/Http/Exception/NetworkException.php @@ -6,7 +6,6 @@ use Psr\Http\Client\NetworkExceptionInterface; use Psr\Http\Message\RequestInterface; -use WordPress\AiClient\Common\Exception\RuntimeException; /** * Exception thrown for network-related errors. @@ -16,7 +15,7 @@ * * @since n.e.x.t */ -class NetworkException extends RuntimeException implements NetworkExceptionInterface +class NetworkException extends RequestException implements NetworkExceptionInterface { /** * The request that failed. diff --git a/src/Providers/Http/Exception/RedirectException.php b/src/Providers/Http/Exception/RedirectException.php new file mode 100644 index 00000000..b12a9c7d --- /dev/null +++ b/src/Providers/Http/Exception/RedirectException.php @@ -0,0 +1,61 @@ +getStatusCode(); + $statusTexts = [ + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + ]; + + $statusText = $statusTexts[$statusCode] ?? 'Redirect'; + + $errorMessage = sprintf( + 'Redirect response (%d %s): Request needs to be retried at a different location', + $statusCode, + $statusText + ); + + // Try to extract the redirect location from headers + $locationValues = $response->getHeader('Location'); + if ($locationValues !== null && !empty($locationValues)) { + $location = $locationValues[0]; + $errorMessage .= ' - Location: ' . $location; + } + + return new self($errorMessage, $statusCode); + } +} diff --git a/src/Providers/Http/Util/ResponseUtil.php b/src/Providers/Http/Util/ResponseUtil.php index 5aee7d57..f4ed4ede 100644 --- a/src/Providers/Http/Util/ResponseUtil.php +++ b/src/Providers/Http/Util/ResponseUtil.php @@ -6,6 +6,7 @@ use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Exception\ClientException; +use WordPress\AiClient\Providers\Http\Exception\RedirectException; use WordPress\AiClient\Providers\Http\Exception\ResponseException; use WordPress\AiClient\Providers\Http\Exception\ServerException; @@ -21,6 +22,7 @@ class ResponseUtil * * This method checks the HTTP status code of the response and throws * the appropriate exception type based on the status code range: + * - 3xx: RedirectException (redirect responses) * - 4xx: ClientException (client errors) * - 5xx: ServerException (server errors) * - Other unsuccessful responses: ResponseException (malformed responses) @@ -28,6 +30,7 @@ class ResponseUtil * @since 0.1.0 * * @param Response $response The HTTP response to check. + * @throws RedirectException If the response indicates a redirect (3xx). * @throws ClientException If the response indicates a client error (4xx). * @throws ServerException If the response indicates a server error (5xx). * @throws ResponseException If the response format is unexpected. @@ -40,6 +43,11 @@ public static function throwIfNotSuccessful(Response $response): void $statusCode = $response->getStatusCode(); + // 3xx Redirect Responses + if ($statusCode >= 300 && $statusCode < 400) { + throw RedirectException::fromRedirectResponse($response); + } + // 4xx Client Errors if ($statusCode >= 400 && $statusCode < 500) { // Special handling for 400 Bad Request @@ -57,8 +65,7 @@ public static function throwIfNotSuccessful(Response $response): void throw ServerException::fromServerError($response); } - // Other unsuccessful responses (3xx redirects, etc.) - these should be rare - // as most HTTP clients handle redirects automatically + // Other unsuccessful responses - should be extremely rare throw ResponseException::fromBadResponse($response); } } diff --git a/tests/unit/Providers/Http/Util/ResponseUtilTest.php b/tests/unit/Providers/Http/Util/ResponseUtilTest.php index 68b81ea3..6d4143cb 100644 --- a/tests/unit/Providers/Http/Util/ResponseUtilTest.php +++ b/tests/unit/Providers/Http/Util/ResponseUtilTest.php @@ -6,7 +6,9 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\Http\DTO\Response; +use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Http\Exception\ResponseException; +use WordPress\AiClient\Providers\Http\Exception\ServerException; use WordPress\AiClient\Providers\Http\Util\ResponseUtil; /** @@ -47,15 +49,60 @@ public function successfulResponseStatusCodeProvider(): array } /** - * Tests that throwIfNotSuccessful throws an exception for unsuccessful responses. + * Tests that throwIfNotSuccessful throws ClientException for 400 Bad Request. * - * @dataProvider unsuccessfulResponseStatusCodeProvider - * @param int $statusCode The unsuccessful HTTP status code. + * @return void + */ + public function testThrowIfNotSuccessfulThrowsClientExceptionFor400BadRequest(): void + { + $response = $this->createMock(Response::class); + $response->method('isSuccessful')->willReturn(false); + $response->method('getStatusCode')->willReturn(400); + $response->method('getBody')->willReturn(''); + + $this->expectException(ClientException::class); + $this->expectExceptionCode(400); + $this->expectExceptionMessage('Bad request (400): Invalid request parameters'); + + ResponseUtil::throwIfNotSuccessful($response); + } + + /** + * Tests that throwIfNotSuccessful throws ClientException for 4xx client errors. + * + * @dataProvider clientErrorStatusCodeProvider + * @param int $statusCode The 4xx HTTP status code. + * @param array $data The response data. + * @param string $expectedMessagePart The expected part of the exception message. + * @return void + */ + public function testThrowIfNotSuccessfulThrowsClientExceptionFor4xxErrors( + int $statusCode, + array $data, + string $expectedMessagePart + ): void { + $response = $this->createMock(Response::class); + $response->method('isSuccessful')->willReturn(false); + $response->method('getStatusCode')->willReturn($statusCode); + $response->method('getData')->willReturn($data); + + $this->expectException(ClientException::class); + $this->expectExceptionCode($statusCode); + $this->expectExceptionMessageMatches("/^Client error \\({$statusCode}\\): Request was rejected due to client-side issue( - {$expectedMessagePart})?$/"); + + ResponseUtil::throwIfNotSuccessful($response); + } + + /** + * Tests that throwIfNotSuccessful throws ServerException for 5xx server errors. + * + * @dataProvider serverErrorStatusCodeProvider + * @param int $statusCode The 5xx HTTP status code. * @param array $data The response data. * @param string $expectedMessagePart The expected part of the exception message. * @return void */ - public function testThrowIfNotSuccessfulThrowsForUnsuccessfulResponses( + public function testThrowIfNotSuccessfulThrowsServerExceptionFor5xxErrors( int $statusCode, array $data, string $expectedMessagePart @@ -65,41 +112,47 @@ public function testThrowIfNotSuccessfulThrowsForUnsuccessfulResponses( $response->method('getStatusCode')->willReturn($statusCode); $response->method('getData')->willReturn($data); - $this->expectException(ResponseException::class); + $this->expectException(ServerException::class); $this->expectExceptionCode($statusCode); - $this->expectExceptionMessageMatches("/^Bad status code: {$statusCode}\.($| {$expectedMessagePart})$/"); + $this->expectExceptionMessageMatches("/^Server error \\({$statusCode}\\): Request failed due to server-side issue( - {$expectedMessagePart})?$/"); ResponseUtil::throwIfNotSuccessful($response); } /** - * Provides unsuccessful HTTP status codes and corresponding data for testing. + * Provides 4xx client error HTTP status codes and corresponding data for testing. * * @return array */ - public function unsuccessfulResponseStatusCodeProvider(): array + public function clientErrorStatusCodeProvider(): array { return [ - '400 Bad Request (no extra message)' => [ - 400, - [], - '', - ], '401 Unauthorized (error.message)' => [ 401, ['error' => ['message' => 'Invalid API key.']], - 'Invalid API key\.', + 'Invalid API key\\.', ], '403 Forbidden (error string)' => [ 403, ['error' => 'Access denied.'], - 'Access denied\.', + 'Access denied\\.', ], '404 Not Found (message string)' => [ 404, ['message' => 'Resource not found.'], - 'Resource not found\.', + 'Resource not found\\.', ], + ]; + } + + /** + * Provides 5xx server error HTTP status codes and corresponding data for testing. + * + * @return array + */ + public function serverErrorStatusCodeProvider(): array + { + return [ '500 Internal Server Error (no extra message)' => [ 500, [], @@ -108,7 +161,7 @@ public function unsuccessfulResponseStatusCodeProvider(): array '503 Service Unavailable (error.message with special chars)' => [ 503, ['error' => ['message' => 'Service is temporarily unavailable. Please try again later.']], - 'Service is temporarily unavailable\. Please try again later\.', + 'Service is temporarily unavailable\\. Please try again later\\.', ], ]; } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php index 89e17e9f..d221cd82 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php @@ -17,6 +17,7 @@ use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\DTO\Response; +use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Http\Exception\ResponseException; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -219,8 +220,8 @@ public function testGenerateImageResultApiFailure(): void $model = $this->createModel(); - $this->expectException(ResponseException::class); - $this->expectExceptionMessage('Bad status code: 400. Bad Request'); + $this->expectException(ClientException::class); + $this->expectExceptionMessage('Bad request (400): {"error": "Bad Request"}'); $model->generateImageResult($prompt); } @@ -612,8 +613,8 @@ public function testThrowIfNotSuccessfulFailure(): void $response = new Response(404, [], '{"error":"Not Found"}'); $model = $this->createModel(); - $this->expectException(ResponseException::class); - $this->expectExceptionMessage('Bad status code: 404. Not Found'); + $this->expectException(ClientException::class); + $this->expectExceptionMessage('Client error (404): Request was rejected due to client-side issue - Not Found'); $model->exposeThrowIfNotSuccessful($response); } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php index 4924fbeb..fe375641 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php @@ -8,6 +8,7 @@ use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\DTO\Response; +use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Http\Exception\ResponseException; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -108,8 +109,8 @@ function (string $modelId) { } ); - $this->expectException(ResponseException::class); - $this->expectExceptionMessage('Bad status code: 400. Bad Request'); + $this->expectException(ClientException::class); + $this->expectExceptionMessage('Bad request (400): {"error": "Bad Request"}'); $directory->listModelMetadata(); } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index cae0b6a9..2497d544 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -17,6 +17,7 @@ use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\DTO\Response; +use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Http\Exception\ResponseException; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -158,8 +159,8 @@ public function testGenerateTextResultApiFailure(): void $model = $this->createModel(); - $this->expectException(ResponseException::class); - $this->expectExceptionMessage('Bad status code: 400. Bad Request'); + $this->expectException(ClientException::class); + $this->expectExceptionMessage('Bad request (400): {"error": "Bad Request"}'); $model->generateTextResult($prompt); } From 44c8a4fb079297aa6b36bfa9b0111cdb069d76b7 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 11 Sep 2025 18:32:59 +0300 Subject: [PATCH 27/39] Enhance ResponseException with semantic factory methods for parsing errors --- .../Http/Exception/ResponseException.php | 13 +++++++ src/Providers/Http/HttpTransporter.php | 1 + ...ctOpenAiCompatibleImageGenerationModel.php | 20 +++++----- ...actOpenAiCompatibleTextGenerationModel.php | 39 ++++++++++--------- .../Providers/Http/Util/ResponseUtilTest.php | 10 +++-- ...enAiCompatibleImageGenerationModelTest.php | 18 ++++----- ...AiCompatibleModelMetadataDirectoryTest.php | 1 - ...penAiCompatibleTextGenerationModelTest.php | 29 +++++++------- 8 files changed, 77 insertions(+), 54 deletions(-) diff --git a/src/Providers/Http/Exception/ResponseException.php b/src/Providers/Http/Exception/ResponseException.php index 2ec6b73f..24acfc76 100644 --- a/src/Providers/Http/Exception/ResponseException.php +++ b/src/Providers/Http/Exception/ResponseException.php @@ -40,6 +40,19 @@ public static function fromMissingData(string $apiName, string $fieldName, strin return new self($message); } + /** + * Creates a ResponseException from invalid data in an API response. + * + * @since n.e.x.t + * + * @param string $apiName The name of the API service (e.g., 'OpenAI', 'Anthropic'). + * @param string $message The specific error message describing the invalid data. + * @return self + */ + public static function fromInvalidData(string $apiName, string $message): self + { + return new self(sprintf('Unexpected %s API response: %s', $apiName, $message)); + } /** * Creates a ResponseException from a bad HTTP response. diff --git a/src/Providers/Http/HttpTransporter.php b/src/Providers/Http/HttpTransporter.php index e4eca15a..3942364f 100644 --- a/src/Providers/Http/HttpTransporter.php +++ b/src/Providers/Http/HttpTransporter.php @@ -16,6 +16,7 @@ use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Exception\NetworkException; + /** * HTTP transporter implementation using HTTPlug. * diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php index 01bdba9a..6e760f9c 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php @@ -293,21 +293,21 @@ protected function parseResponseToGenerativeAiResult( /** @var ResponseData $responseData */ $responseData = $response->getData(); if (!isset($responseData['data']) || !$responseData['data']) { - throw new RuntimeException( - 'Unexpected API response: Missing the data key.' - ); + throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'data'); } if (!is_array($responseData['data'])) { - throw new RuntimeException( - 'Unexpected API response: The data key must contain an array.' + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + 'The data key must contain an array.' ); } $candidates = []; foreach ($responseData['data'] as $choiceData) { if (!is_array($choiceData) || array_is_list($choiceData)) { - throw new RuntimeException( - 'Unexpected API response: Each element in the data key must be an associative array.' + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + 'Each element in the data key must be an associative array.' ); } @@ -361,8 +361,10 @@ protected function parseResponseChoiceToCandidate( } elseif (isset($choiceData['b64_json']) && is_string($choiceData['b64_json'])) { $imageFile = new File($choiceData['b64_json'], $expectedMimeType); } else { - throw new RuntimeException( - 'Unexpected API response: Each choice must contain either a url or b64_json key with a string value.' + throw ResponseException::fromMissingData( + $this->providerMetadata()->getName(), + 'url or b64_json', + 'choice data' ); } diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index 8264867c..5bfb0009 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -556,21 +556,21 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera /** @var ResponseData $responseData */ $responseData = $response->getData(); if (!isset($responseData['choices']) || !$responseData['choices']) { - throw new RuntimeException( - 'Unexpected API response: Missing the choices key.' - ); + throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'choices'); } if (!is_array($responseData['choices'])) { - throw new RuntimeException( - 'Unexpected API response: The choices key must contain an array.' + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + 'The choices key must contain an array.' ); } $candidates = []; foreach ($responseData['choices'] as $choiceData) { if (!is_array($choiceData) || array_is_list($choiceData)) { - throw new RuntimeException( - 'Unexpected API response: Each element in the choices key must be an associative array.' + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + 'Each element in the choices key must be an associative array.' ); } @@ -621,14 +621,18 @@ protected function parseResponseChoiceToCandidate(array $choiceData): Candidate !is_array($choiceData['message']) || array_is_list($choiceData['message']) ) { - throw new RuntimeException( - 'Unexpected API response: Each choice must contain a message key with an associative array.' + throw ResponseException::fromMissingData( + $this->providerMetadata()->getName(), + 'message', + 'choice data' ); } if (!isset($choiceData['finish_reason']) || !is_string($choiceData['finish_reason'])) { - throw new RuntimeException( - 'Unexpected API response: Each choice must contain a finish_reason key with a string value.' + throw ResponseException::fromMissingData( + $this->providerMetadata()->getName(), + 'finish_reason', + 'choice data' ); } @@ -649,11 +653,9 @@ protected function parseResponseChoiceToCandidate(array $choiceData): Candidate $finishReason = FinishReasonEnum::toolCalls(); break; default: - throw new RuntimeException( - sprintf( - 'Unexpected API response: Invalid finish reason "%s".', - $choiceData['finish_reason'] - ) + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + sprintf('Invalid finish reason "%s".', $choiceData['finish_reason']) ); } @@ -703,8 +705,9 @@ protected function parseResponseChoiceMessageParts(array $messageData): array foreach ($messageData['tool_calls'] as $toolCallData) { $toolCallPart = $this->parseResponseChoiceMessageToolCallPart($toolCallData); if (!$toolCallPart) { - throw new RuntimeException( - 'Unexpected API response: The response includes a tool call of an unexpected type.' + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + 'The response includes a tool call of an unexpected type.' ); } $parts[] = $toolCallPart; diff --git a/tests/unit/Providers/Http/Util/ResponseUtilTest.php b/tests/unit/Providers/Http/Util/ResponseUtilTest.php index 6d4143cb..b0f39a55 100644 --- a/tests/unit/Providers/Http/Util/ResponseUtilTest.php +++ b/tests/unit/Providers/Http/Util/ResponseUtilTest.php @@ -7,7 +7,6 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Exception\ClientException; -use WordPress\AiClient\Providers\Http\Exception\ResponseException; use WordPress\AiClient\Providers\Http\Exception\ServerException; use WordPress\AiClient\Providers\Http\Util\ResponseUtil; @@ -88,7 +87,10 @@ public function testThrowIfNotSuccessfulThrowsClientExceptionFor4xxErrors( $this->expectException(ClientException::class); $this->expectExceptionCode($statusCode); - $this->expectExceptionMessageMatches("/^Client error \\({$statusCode}\\): Request was rejected due to client-side issue( - {$expectedMessagePart})?$/"); + $this->expectExceptionMessageMatches( + "/^Client error \\({$statusCode}\\): Request was rejected due to " . + "client-side issue( - {$expectedMessagePart})?$/" + ); ResponseUtil::throwIfNotSuccessful($response); } @@ -114,7 +116,9 @@ public function testThrowIfNotSuccessfulThrowsServerExceptionFor5xxErrors( $this->expectException(ServerException::class); $this->expectExceptionCode($statusCode); - $this->expectExceptionMessageMatches("/^Server error \\({$statusCode}\\): Request failed due to server-side issue( - {$expectedMessagePart})?$/"); + $this->expectExceptionMessageMatches( + "/^Server error \\({$statusCode}\\): Request failed due to server-side issue( - {$expectedMessagePart})?$/" + ); ResponseUtil::throwIfNotSuccessful($response); } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php index d221cd82..cd301833 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php @@ -6,7 +6,6 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; -use RuntimeException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; @@ -58,6 +57,7 @@ protected function setUp(): void $this->modelMetadata = $this->createStub(ModelMetadata::class); $this->modelMetadata->method('getId')->willReturn('test-image-model'); $this->providerMetadata = $this->createStub(ProviderMetadata::class); + $this->providerMetadata->method('getName')->willReturn('TestProvider'); $this->mockHttpTransporter = $this->createMock(HttpTransporterInterface::class); $this->mockRequestAuthentication = $this->createMock(RequestAuthenticationInterface::class); } @@ -720,8 +720,8 @@ public function testParseResponseToGenerativeAiResultMissingData(): void $response = new Response(200, [], json_encode(['id' => 'test-id'])); $model = $this->createModel(); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unexpected API response: Missing the data key.'); + $this->expectException(ResponseException::class); + $this->expectExceptionMessage('Unexpected TestProvider API response: Missing the "data" key.'); $model->exposeParseResponseToGenerativeAiResult($response); } @@ -740,8 +740,8 @@ public function testParseResponseToGenerativeAiResultInvalidDataType(): void ); $model = $this->createModel(); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unexpected API response: The data key must contain an array.'); + $this->expectException(ResponseException::class); + $this->expectExceptionMessage('Unexpected TestProvider API response: The data key must contain an array.'); $model->exposeParseResponseToGenerativeAiResult($response); } @@ -756,9 +756,9 @@ public function testParseResponseToGenerativeAiResultInvalidChoiceElementType(): $response = new Response(200, [], json_encode(['data' => ['invalid']])); $model = $this->createModel(); - $this->expectException(RuntimeException::class); + $this->expectException(ResponseException::class); $this->expectExceptionMessage( - 'Unexpected API response: Each element in the data key must be an associative array.' + 'Unexpected TestProvider API response: Each element in the data key must be an associative array.' ); $model->exposeParseResponseToGenerativeAiResult($response); @@ -820,9 +820,9 @@ public function testParseResponseChoiceToCandidateMissingUrlOrB64Json(): void ]; $model = $this->createModel(); - $this->expectException(RuntimeException::class); + $this->expectException(ResponseException::class); $this->expectExceptionMessage( - 'Unexpected API response: Each choice must contain either a url or b64_json key with a string value.' + 'Unexpected TestProvider API response: Missing the "url or b64_json" key in choice data.' ); $model->exposeParseResponseChoiceToCandidate($choiceData); diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php index fe375641..a096889f 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php @@ -9,7 +9,6 @@ use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Exception\ClientException; -use WordPress\AiClient\Providers\Http\Exception\ResponseException; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; /** diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 2497d544..2055744b 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -60,6 +60,7 @@ protected function setUp(): void $this->modelMetadata = $this->createStub(ModelMetadata::class); $this->modelMetadata->method('getId')->willReturn('test-model'); $this->providerMetadata = $this->createStub(ProviderMetadata::class); + $this->providerMetadata->method('getName')->willReturn('TestProvider'); $this->mockHttpTransporter = $this->createMock(HttpTransporterInterface::class); $this->mockRequestAuthentication = $this->createMock(RequestAuthenticationInterface::class); } @@ -950,8 +951,8 @@ public function testParseResponseToGenerativeAiResultMissingChoices(): void $response = new Response(200, [], json_encode(['id' => 'test-id'])); $model = $this->createModel(); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unexpected API response: Missing the choices key.'); + $this->expectException(ResponseException::class); + $this->expectExceptionMessage('Unexpected TestProvider API response: Missing the "choices" key.'); $model->parseResponseToGenerativeAiResult($response); } @@ -970,8 +971,8 @@ public function testParseResponseToGenerativeAiResultInvalidChoicesType(): void ); $model = $this->createModel(); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unexpected API response: The choices key must contain an array.'); + $this->expectException(ResponseException::class); + $this->expectExceptionMessage('Unexpected TestProvider API response: The choices key must contain an array.'); $model->parseResponseToGenerativeAiResult($response); } @@ -986,9 +987,9 @@ public function testParseResponseToGenerativeAiResultInvalidChoiceElementType(): $response = new Response(200, [], json_encode(['choices' => ['invalid']])); $model = $this->createModel(); - $this->expectException(RuntimeException::class); + $this->expectException(ResponseException::class); $this->expectExceptionMessage( - 'Unexpected API response: Each element in the choices key must be an associative array.' + 'Unexpected TestProvider API response: Each element in the choices key must be an associative array.' ); $model->parseResponseToGenerativeAiResult($response); @@ -1028,9 +1029,9 @@ public function testParseResponseChoiceToCandidateMissingMessage(): void ]; $model = $this->createModel(); - $this->expectException(RuntimeException::class); + $this->expectException(ResponseException::class); $this->expectExceptionMessage( - 'Unexpected API response: Each choice must contain a message key with an associative array.' + 'Unexpected TestProvider API response: Missing the "message" key in choice data.' ); $model->exposeParseResponseChoiceToCandidate($choiceData); @@ -1049,9 +1050,9 @@ public function testParseResponseChoiceToCandidateInvalidMessageType(): void ]; $model = $this->createModel(); - $this->expectException(RuntimeException::class); + $this->expectException(ResponseException::class); $this->expectExceptionMessage( - 'Unexpected API response: Each choice must contain a message key with an associative array.' + 'Unexpected TestProvider API response: Missing the "message" key in choice data.' ); $model->exposeParseResponseChoiceToCandidate($choiceData); @@ -1072,9 +1073,9 @@ public function testParseResponseChoiceToCandidateMissingFinishReason(): void ]; $model = $this->createModel(); - $this->expectException(RuntimeException::class); + $this->expectException(ResponseException::class); $this->expectExceptionMessage( - 'Unexpected API response: Each choice must contain a finish_reason key with a string value.' + 'Unexpected TestProvider API response: Missing the "finish_reason" key in choice data.' ); $model->exposeParseResponseChoiceToCandidate($choiceData); @@ -1096,8 +1097,8 @@ public function testParseResponseChoiceToCandidateInvalidFinishReason(): void ]; $model = $this->createModel(); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unexpected API response: Invalid finish reason "unknown".'); + $this->expectException(ResponseException::class); + $this->expectExceptionMessage('Unexpected TestProvider API response: Invalid finish reason "unknown".'); $model->exposeParseResponseChoiceToCandidate($choiceData); } From 43141340fcf8d81cd8c29f901380a01e89ffedf6 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 25 Sep 2025 16:40:27 +0300 Subject: [PATCH 28/39] Build centralized error message extraction utility Add ErrorMessageExtractor utility to centralize parsing of common API error response formats across exception classes. Handles standard patterns like { "error": { "message": "..." } }, { "error": "..." }, and { "message": "..." }. Implement fromPsrRequest static factory method in Request DTO to support PSR-7 to internal DTO conversion for exception context storage. --- src/Providers/Http/DTO/Request.php | 26 +++++++++ .../Http/Utilities/ErrorMessageExtractor.php | 58 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/Providers/Http/Utilities/ErrorMessageExtractor.php diff --git a/src/Providers/Http/DTO/Request.php b/src/Providers/Http/DTO/Request.php index 68f873c3..2e872b76 100644 --- a/src/Providers/Http/DTO/Request.php +++ b/src/Providers/Http/DTO/Request.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Providers\Http\DTO; use JsonException; +use Psr\Http\Message\RequestInterface; use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Providers\Http\Collections\HeadersCollection; @@ -355,4 +356,29 @@ public static function fromArray(array $array): self $array[self::KEY_BODY] ?? null ); } + + /** + * Creates a Request instance from a PSR-7 RequestInterface. + * + * @since n.e.x.t + * + * @param RequestInterface $psrRequest The PSR-7 request to convert. + * @return self A new Request instance. + * @throws InvalidArgumentException If the HTTP method is not supported. + */ + public static function fromPsrRequest(RequestInterface $psrRequest): self + { + $method = HttpMethodEnum::from($psrRequest->getMethod()); + $uri = (string) $psrRequest->getUri(); + + // Convert PSR-7 headers to array format expected by our constructor + /** @var array> $headers */ + $headers = $psrRequest->getHeaders(); + + // Get body content + $body = $psrRequest->getBody()->getContents(); + $bodyOrData = !empty($body) ? $body : null; + + return new self($method, $uri, $headers, $bodyOrData); + } } diff --git a/src/Providers/Http/Utilities/ErrorMessageExtractor.php b/src/Providers/Http/Utilities/ErrorMessageExtractor.php new file mode 100644 index 00000000..5f957ca9 --- /dev/null +++ b/src/Providers/Http/Utilities/ErrorMessageExtractor.php @@ -0,0 +1,58 @@ + Date: Thu, 25 Sep 2025 16:40:42 +0300 Subject: [PATCH 29/39] Implement semantic exception hierarchy with proper inheritance patterns --- .../Http/Exception/ClientException.php | 96 ++++++++++++++----- .../Http/Exception/NetworkException.php | 56 +++++------ .../Http/Exception/RequestException.php | 84 ---------------- 3 files changed, 102 insertions(+), 134 deletions(-) delete mode 100644 src/Providers/Http/Exception/RequestException.php diff --git a/src/Providers/Http/Exception/ClientException.php b/src/Providers/Http/Exception/ClientException.php index 6d2da28f..6a06d205 100644 --- a/src/Providers/Http/Exception/ClientException.php +++ b/src/Providers/Http/Exception/ClientException.php @@ -4,7 +4,11 @@ namespace WordPress\AiClient\Providers\Http\Exception; +use Psr\Http\Message\RequestInterface; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; +use WordPress\AiClient\Providers\Http\Utilities\ErrorMessageExtractor; /** * Exception thrown for 4xx HTTP client errors. @@ -14,8 +18,35 @@ * * @since n.e.x.t */ -class ClientException extends RequestException +class ClientException extends InvalidArgumentException { + /** + * The request that failed. + * + * @var Request|null + */ + protected ?Request $request = null; + + /** + * Returns the request that failed as our Request DTO. + * + * @since n.e.x.t + * + * @return Request + * @throws \RuntimeException If no request is available + */ + public function getRequest(): Request + { + if ($this->request === null) { + throw new \RuntimeException( + 'Request object not available. This exception was directly instantiated. ' . + 'Use a factory method that provides request context.' + ); + } + + return $this->request; + } + /** * Creates a ClientException from a 400 Bad Request response. * @@ -30,6 +61,43 @@ public static function fromBadRequestResponse(string $errorDetail = 'Invalid req return new self($message, 400); } + /** + * Creates a ClientException from a bad request. + * + * @since n.e.x.t + * + * @param RequestInterface $psrRequest The PSR-7 request that failed. + * @param string $errorDetail Details about what made the request bad. + * @return self + */ + public static function fromBadRequest( + RequestInterface $psrRequest, + string $errorDetail = 'Invalid request parameters' + ): self { + $request = Request::fromPsrRequest($psrRequest); + $message = sprintf('Bad request to %s (400): %s', $request->getUri(), $errorDetail); + + $exception = new self($message, 400); + $exception->request = $request; + return $exception; + } + + /** + * Creates a ClientException from a bad request to a specific URI. + * + * @since n.e.x.t + * + * @param string $uri The URI that was requested. + * @param string $errorDetail Details about what made the request bad. + * @return self + * + * @deprecated Use fromBadRequest() with RequestInterface for better type safety + */ + public static function fromBadRequestToUri(string $uri, string $errorDetail = 'Invalid request parameters'): self + { + return new self(sprintf('Bad request to %s (400): %s', $uri, $errorDetail), 400); + } + /** * Creates a ClientException from a client error response (4xx). * @@ -48,28 +116,10 @@ public static function fromClientError(Response $response): self $response->getStatusCode() ); - // Handle common error formats in API responses - $data = $response->getData(); - if ( - is_array($data) && - isset($data['error']) && - is_array($data['error']) && - isset($data['error']['message']) && - is_string($data['error']['message']) - ) { - $errorMessage .= ' - ' . $data['error']['message']; - } elseif ( - is_array($data) && - isset($data['error']) && - is_string($data['error']) - ) { - $errorMessage .= ' - ' . $data['error']; - } elseif ( - is_array($data) && - isset($data['message']) && - is_string($data['message']) - ) { - $errorMessage .= ' - ' . $data['message']; + // Extract error message from response data using centralized utility + $extractedError = ErrorMessageExtractor::extractFromResponseData($response->getData()); + if ($extractedError !== null) { + $errorMessage .= ' - ' . $extractedError; } return new self($errorMessage, $response->getStatusCode()); diff --git a/src/Providers/Http/Exception/NetworkException.php b/src/Providers/Http/Exception/NetworkException.php index 0109f544..e99bd878 100644 --- a/src/Providers/Http/Exception/NetworkException.php +++ b/src/Providers/Http/Exception/NetworkException.php @@ -4,8 +4,9 @@ namespace WordPress\AiClient\Providers\Http\Exception; -use Psr\Http\Client\NetworkExceptionInterface; use Psr\Http\Message\RequestInterface; +use WordPress\AiClient\Common\Exception\RuntimeException; +use WordPress\AiClient\Providers\Http\DTO\Request; /** * Exception thrown for network-related errors. @@ -15,29 +16,50 @@ * * @since n.e.x.t */ -class NetworkException extends RequestException implements NetworkExceptionInterface +class NetworkException extends RuntimeException { /** * The request that failed. * - * @var RequestInterface|null + * @var Request|null */ - private ?RequestInterface $request = null; + protected ?Request $request = null; + + /** + * Returns the request that failed as our Request DTO. + * + * @since n.e.x.t + * + * @return Request + * @throws \RuntimeException If no request is available + */ + public function getRequest(): Request + { + if ($this->request === null) { + throw new \RuntimeException( + 'Request object not available. This exception was directly instantiated. ' . + 'Use a factory method that provides request context.' + ); + } + + return $this->request; + } /** * Creates a NetworkException from a PSR-18 network exception. * * @since n.e.x.t * - * @param RequestInterface $request The request that failed. + * @param RequestInterface $psrRequest The PSR-7 request that failed. * @param \Throwable $networkException The PSR-18 network exception. * @return self */ - public static function fromPsr18NetworkException(RequestInterface $request, \Throwable $networkException): self + public static function fromPsr18NetworkException(RequestInterface $psrRequest, \Throwable $networkException): self { + $request = Request::fromPsrRequest($psrRequest); $message = sprintf( 'Network error occurred while sending request to %s: %s', - (string) $request->getUri(), + $request->getUri(), $networkException->getMessage() ); @@ -45,24 +67,4 @@ public static function fromPsr18NetworkException(RequestInterface $request, \Thr $exception->request = $request; return $exception; } - - /** - * Returns the request that failed. - * - * @since n.e.x.t - * - * @return RequestInterface - * @throws \RuntimeException If no request is available (when directly instantiated) - */ - public function getRequest(): RequestInterface - { - if ($this->request === null) { - throw new \RuntimeException( - 'Request object not available. This exception was directly instantiated. ' . - 'Use fromPsr18NetworkException() factory method for PSR-18 compliance.' - ); - } - - return $this->request; - } } diff --git a/src/Providers/Http/Exception/RequestException.php b/src/Providers/Http/Exception/RequestException.php deleted file mode 100644 index 70420a35..00000000 --- a/src/Providers/Http/Exception/RequestException.php +++ /dev/null @@ -1,84 +0,0 @@ -getUri(), $errorDetail); - - $exception = new self($message); - $exception->request = $request; - return $exception; - } - - /** - * Creates a RequestException from a bad request to a specific URI. - * - * @since n.e.x.t - * - * @param string $uri The URI that was requested. - * @param string $errorDetail Details about what made the request bad. - * @return self - * - * @deprecated Use fromBadRequest() with RequestInterface for PSR-18 compliance - */ - public static function fromBadRequestToUri(string $uri, string $errorDetail = 'Invalid request parameters'): self - { - return new self(sprintf('Bad request to %s (400): %s', $uri, $errorDetail)); - } - - /** - * Returns the request that failed. - * - * @since n.e.x.t - * - * @return RequestInterface - * @throws \RuntimeException If no request is available (when using deprecated fromBadRequestToUri) - */ - public function getRequest(): RequestInterface - { - if ($this->request === null) { - throw new \RuntimeException( - 'Request object not available. This exception was created using the deprecated ' . - 'fromBadRequestToUri() method. Use fromBadRequest() instead for PSR-18 compliance.' - ); - } - - return $this->request; - } -} From 2867cbce51a9fdfb04cc90ed6e161c76d1f437bd Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 25 Sep 2025 16:40:52 +0300 Subject: [PATCH 30/39] Update exception classes to use centralized error extraction and Request DTOs --- .../Http/Exception/RedirectException.php | 3 +- .../Http/Exception/ResponseException.php | 30 ++++--------------- .../Http/Exception/ServerException.php | 30 +++++-------------- 3 files changed, 15 insertions(+), 48 deletions(-) diff --git a/src/Providers/Http/Exception/RedirectException.php b/src/Providers/Http/Exception/RedirectException.php index b12a9c7d..fa4d80ca 100644 --- a/src/Providers/Http/Exception/RedirectException.php +++ b/src/Providers/Http/Exception/RedirectException.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\Providers\Http\Exception; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Http\DTO\Response; /** @@ -15,7 +16,7 @@ * * @since n.e.x.t */ -class RedirectException extends RequestException +class RedirectException extends RuntimeException { /** * Creates a RedirectException from a redirect response. diff --git a/src/Providers/Http/Exception/ResponseException.php b/src/Providers/Http/Exception/ResponseException.php index 24acfc76..a10fd7dd 100644 --- a/src/Providers/Http/Exception/ResponseException.php +++ b/src/Providers/Http/Exception/ResponseException.php @@ -4,9 +4,9 @@ namespace WordPress\AiClient\Providers\Http\Exception; -use Psr\Http\Client\ClientExceptionInterface; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Http\DTO\Response; +use WordPress\AiClient\Providers\Http\Utilities\ErrorMessageExtractor; /** * Exception class for HTTP response errors. @@ -17,7 +17,7 @@ * * @since 0.1.0 */ -class ResponseException extends RuntimeException implements ClientExceptionInterface +class ResponseException extends RuntimeException { /** * Creates a ResponseException for missing expected data. @@ -72,28 +72,10 @@ public static function fromBadResponse(Response $response): self $response->getStatusCode() ); - // Handle common error formats in API responses. - $data = $response->getData(); - if ( - is_array($data) && - isset($data['error']) && - is_array($data['error']) && - isset($data['error']['message']) && - is_string($data['error']['message']) - ) { - $errorMessage .= ' ' . $data['error']['message']; - } elseif ( - is_array($data) && - isset($data['error']) && - is_string($data['error']) - ) { - $errorMessage .= ' ' . $data['error']; - } elseif ( - is_array($data) && - isset($data['message']) && - is_string($data['message']) - ) { - $errorMessage .= ' ' . $data['message']; + // Extract error message from response data using centralized utility + $extractedError = ErrorMessageExtractor::extractFromResponseData($response->getData()); + if ($extractedError !== null) { + $errorMessage .= ' ' . $extractedError; } return new self($errorMessage, $response->getStatusCode()); diff --git a/src/Providers/Http/Exception/ServerException.php b/src/Providers/Http/Exception/ServerException.php index 0fd1c507..0ecb8cac 100644 --- a/src/Providers/Http/Exception/ServerException.php +++ b/src/Providers/Http/Exception/ServerException.php @@ -4,7 +4,9 @@ namespace WordPress\AiClient\Providers\Http\Exception; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Http\DTO\Response; +use WordPress\AiClient\Providers\Http\Utilities\ErrorMessageExtractor; /** * Exception thrown for 5xx HTTP server errors. @@ -14,7 +16,7 @@ * * @since n.e.x.t */ -class ServerException extends RequestException +class ServerException extends RuntimeException { /** * Creates a ServerException from a server error response. @@ -34,28 +36,10 @@ public static function fromServerError(Response $response): self $response->getStatusCode() ); - // Handle common error formats in API responses - $data = $response->getData(); - if ( - is_array($data) && - isset($data['error']) && - is_array($data['error']) && - isset($data['error']['message']) && - is_string($data['error']['message']) - ) { - $errorMessage .= ' - ' . $data['error']['message']; - } elseif ( - is_array($data) && - isset($data['error']) && - is_string($data['error']) - ) { - $errorMessage .= ' - ' . $data['error']; - } elseif ( - is_array($data) && - isset($data['message']) && - is_string($data['message']) - ) { - $errorMessage .= ' - ' . $data['message']; + // Extract error message from response data using centralized utility + $extractedError = ErrorMessageExtractor::extractFromResponseData($response->getData()); + if ($extractedError !== null) { + $errorMessage .= ' - ' . $extractedError; } return new self($errorMessage, $response->getStatusCode()); From e16fba41c20d93e4559caaa69001a8ce0f35fa59 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 25 Sep 2025 16:41:09 +0300 Subject: [PATCH 31/39] Update exception tests for new hierarchy structure --- tests/unit/Exceptions/ExceptionsTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/Exceptions/ExceptionsTest.php b/tests/unit/Exceptions/ExceptionsTest.php index a7b5da06..f5d31695 100644 --- a/tests/unit/Exceptions/ExceptionsTest.php +++ b/tests/unit/Exceptions/ExceptionsTest.php @@ -8,8 +8,8 @@ use WordPress\AiClient\Common\Contracts\AiClientExceptionInterface; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; +use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Http\Exception\NetworkException; -use WordPress\AiClient\Providers\Http\Exception\RequestException; /** * Tests for AI Client exceptions. @@ -19,7 +19,7 @@ * @covers \WordPress\AiClient\Common\Exception\RuntimeException * @covers \WordPress\AiClient\Common\Contracts\AiClientExceptionInterface * @covers \WordPress\AiClient\Providers\Http\Exception\NetworkException - * @covers \WordPress\AiClient\Providers\Http\Exception\RequestException + * @covers \WordPress\AiClient\Providers\Http\Exception\ClientException */ class ExceptionsTest extends TestCase { @@ -29,7 +29,7 @@ public function testAllExceptionsImplementAiClientExceptionInterface(): void new InvalidArgumentException('test'), new RuntimeException('test'), new NetworkException('test'), - new RequestException('test'), + new ClientException('test'), ]; foreach ($exceptions as $exception) { @@ -43,7 +43,7 @@ public function testCatchAllFunctionality(): void new InvalidArgumentException('invalid error'), new RuntimeException('runtime error'), new NetworkException('network error'), - new RequestException('request error'), + new ClientException('client error'), ]; foreach ($exceptions as $exception) { From 5e765f88f996bfb17dee1584be394997e32115c5 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Mon, 29 Sep 2025 23:02:04 +0300 Subject: [PATCH 32/39] Remove special 400 handling from ResponseUtil --- src/Providers/Http/Util/ResponseUtil.php | 7 ------- tests/unit/Providers/Http/Util/ResponseUtilTest.php | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/Providers/Http/Util/ResponseUtil.php b/src/Providers/Http/Util/ResponseUtil.php index f4ed4ede..f3eb8624 100644 --- a/src/Providers/Http/Util/ResponseUtil.php +++ b/src/Providers/Http/Util/ResponseUtil.php @@ -50,13 +50,6 @@ public static function throwIfNotSuccessful(Response $response): void // 4xx Client Errors if ($statusCode >= 400 && $statusCode < 500) { - // Special handling for 400 Bad Request - if ($statusCode === 400) { - $body = (string) $response->getBody(); - $errorDetail = $body ? substr($body, 0, 200) : 'Invalid request parameters'; - throw ClientException::fromBadRequestResponse($errorDetail); - } - // General 4xx client errors throw ClientException::fromClientError($response); } diff --git a/tests/unit/Providers/Http/Util/ResponseUtilTest.php b/tests/unit/Providers/Http/Util/ResponseUtilTest.php index b0f39a55..129b1c0c 100644 --- a/tests/unit/Providers/Http/Util/ResponseUtilTest.php +++ b/tests/unit/Providers/Http/Util/ResponseUtilTest.php @@ -57,11 +57,11 @@ public function testThrowIfNotSuccessfulThrowsClientExceptionFor400BadRequest(): $response = $this->createMock(Response::class); $response->method('isSuccessful')->willReturn(false); $response->method('getStatusCode')->willReturn(400); - $response->method('getBody')->willReturn(''); + $response->method('getData')->willReturn([]); $this->expectException(ClientException::class); $this->expectExceptionCode(400); - $this->expectExceptionMessage('Bad request (400): Invalid request parameters'); + $this->expectExceptionMessage('Client error (400): Request was rejected due to client-side issue'); ResponseUtil::throwIfNotSuccessful($response); } From 01bafd9ef6ba9bb3c8c2e52335803edce322f08c Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Mon, 29 Sep 2025 23:09:59 +0300 Subject: [PATCH 33/39] Rename fromClientError to fromClientErrorResponse and fix test expectations --- src/Providers/Http/Exception/ClientException.php | 2 +- src/Providers/Http/Util/ResponseUtil.php | 2 +- .../AbstractOpenAiCompatibleImageGenerationModelTest.php | 2 +- .../AbstractOpenAiCompatibleModelMetadataDirectoryTest.php | 2 +- .../AbstractOpenAiCompatibleTextGenerationModelTest.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Providers/Http/Exception/ClientException.php b/src/Providers/Http/Exception/ClientException.php index 6a06d205..6304d36a 100644 --- a/src/Providers/Http/Exception/ClientException.php +++ b/src/Providers/Http/Exception/ClientException.php @@ -109,7 +109,7 @@ public static function fromBadRequestToUri(string $uri, string $errorDetail = 'I * @param Response $response The HTTP response that failed. * @return self */ - public static function fromClientError(Response $response): self + public static function fromClientErrorResponse(Response $response): self { $errorMessage = sprintf( 'Client error (%d): Request was rejected due to client-side issue', diff --git a/src/Providers/Http/Util/ResponseUtil.php b/src/Providers/Http/Util/ResponseUtil.php index f3eb8624..4817b885 100644 --- a/src/Providers/Http/Util/ResponseUtil.php +++ b/src/Providers/Http/Util/ResponseUtil.php @@ -50,7 +50,7 @@ public static function throwIfNotSuccessful(Response $response): void // 4xx Client Errors if ($statusCode >= 400 && $statusCode < 500) { - throw ClientException::fromClientError($response); + throw ClientException::fromClientErrorResponse($response); } // 5xx Server Errors diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php index cd301833..39558e38 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php @@ -221,7 +221,7 @@ public function testGenerateImageResultApiFailure(): void $model = $this->createModel(); $this->expectException(ClientException::class); - $this->expectExceptionMessage('Bad request (400): {"error": "Bad Request"}'); + $this->expectExceptionMessage('Client error (400): Request was rejected due to client-side issue - Bad Request'); $model->generateImageResult($prompt); } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php index a096889f..0287a4a5 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php @@ -109,7 +109,7 @@ function (string $modelId) { ); $this->expectException(ClientException::class); - $this->expectExceptionMessage('Bad request (400): {"error": "Bad Request"}'); + $this->expectExceptionMessage('Client error (400): Request was rejected due to client-side issue - Bad Request'); $directory->listModelMetadata(); } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 2055744b..af833f6a 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -161,7 +161,7 @@ public function testGenerateTextResultApiFailure(): void $model = $this->createModel(); $this->expectException(ClientException::class); - $this->expectExceptionMessage('Bad request (400): {"error": "Bad Request"}'); + $this->expectExceptionMessage('Client error (400): Request was rejected due to client-side issue - Bad Request'); $model->generateTextResult($prompt); } From 7bbaaddbff4c53b252cebb5959bb9075d046ce78 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Mon, 29 Sep 2025 23:22:03 +0300 Subject: [PATCH 34/39] Remove unnecessary ClientException static methods and add status mapping --- .../Http/Exception/ClientException.php | 68 ++++--------------- .../Providers/Http/Util/ResponseUtilTest.php | 4 +- ...enAiCompatibleImageGenerationModelTest.php | 8 ++- ...AiCompatibleModelMetadataDirectoryTest.php | 4 +- ...penAiCompatibleTextGenerationModelTest.php | 4 +- 5 files changed, 29 insertions(+), 59 deletions(-) diff --git a/src/Providers/Http/Exception/ClientException.php b/src/Providers/Http/Exception/ClientException.php index 6304d36a..45a17352 100644 --- a/src/Providers/Http/Exception/ClientException.php +++ b/src/Providers/Http/Exception/ClientException.php @@ -47,57 +47,6 @@ public function getRequest(): Request return $this->request; } - /** - * Creates a ClientException from a 400 Bad Request response. - * - * @since n.e.x.t - * - * @param string $errorDetail Details about what made the request bad. - * @return self - */ - public static function fromBadRequestResponse(string $errorDetail = 'Invalid request parameters'): self - { - $message = sprintf('Bad request (400): %s', $errorDetail); - return new self($message, 400); - } - - /** - * Creates a ClientException from a bad request. - * - * @since n.e.x.t - * - * @param RequestInterface $psrRequest The PSR-7 request that failed. - * @param string $errorDetail Details about what made the request bad. - * @return self - */ - public static function fromBadRequest( - RequestInterface $psrRequest, - string $errorDetail = 'Invalid request parameters' - ): self { - $request = Request::fromPsrRequest($psrRequest); - $message = sprintf('Bad request to %s (400): %s', $request->getUri(), $errorDetail); - - $exception = new self($message, 400); - $exception->request = $request; - return $exception; - } - - /** - * Creates a ClientException from a bad request to a specific URI. - * - * @since n.e.x.t - * - * @param string $uri The URI that was requested. - * @param string $errorDetail Details about what made the request bad. - * @return self - * - * @deprecated Use fromBadRequest() with RequestInterface for better type safety - */ - public static function fromBadRequestToUri(string $uri, string $errorDetail = 'Invalid request parameters'): self - { - return new self(sprintf('Bad request to %s (400): %s', $uri, $errorDetail), 400); - } - /** * Creates a ClientException from a client error response (4xx). * @@ -111,9 +60,22 @@ public static function fromBadRequestToUri(string $uri, string $errorDetail = 'I */ public static function fromClientErrorResponse(Response $response): self { + $statusCode = $response->getStatusCode(); + $statusTexts = [ + 400 => 'Bad Request', + 401 => 'Unauthorized', + 403 => 'Forbidden', + 404 => 'Not Found', + 422 => 'Unprocessable Entity', + 429 => 'Too Many Requests', + ]; + + $statusText = $statusTexts[$statusCode] ?? 'Client Error'; + $errorMessage = sprintf( - 'Client error (%d): Request was rejected due to client-side issue', - $response->getStatusCode() + 'Client error (%d %s): Request was rejected due to client-side issue', + $statusCode, + $statusText ); // Extract error message from response data using centralized utility diff --git a/tests/unit/Providers/Http/Util/ResponseUtilTest.php b/tests/unit/Providers/Http/Util/ResponseUtilTest.php index 129b1c0c..e56831aa 100644 --- a/tests/unit/Providers/Http/Util/ResponseUtilTest.php +++ b/tests/unit/Providers/Http/Util/ResponseUtilTest.php @@ -61,7 +61,7 @@ public function testThrowIfNotSuccessfulThrowsClientExceptionFor400BadRequest(): $this->expectException(ClientException::class); $this->expectExceptionCode(400); - $this->expectExceptionMessage('Client error (400): Request was rejected due to client-side issue'); + $this->expectExceptionMessage('Client error (400 Bad Request): Request was rejected due to client-side issue'); ResponseUtil::throwIfNotSuccessful($response); } @@ -88,7 +88,7 @@ public function testThrowIfNotSuccessfulThrowsClientExceptionFor4xxErrors( $this->expectException(ClientException::class); $this->expectExceptionCode($statusCode); $this->expectExceptionMessageMatches( - "/^Client error \\({$statusCode}\\): Request was rejected due to " . + "/^Client error \\({$statusCode} [^)]+\\): Request was rejected due to " . "client-side issue( - {$expectedMessagePart})?$/" ); diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php index 39558e38..7e85f9e5 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php @@ -221,7 +221,9 @@ public function testGenerateImageResultApiFailure(): void $model = $this->createModel(); $this->expectException(ClientException::class); - $this->expectExceptionMessage('Client error (400): Request was rejected due to client-side issue - Bad Request'); + $this->expectExceptionMessage( + 'Client error (400 Bad Request): Request was rejected due to client-side issue - Bad Request' + ); $model->generateImageResult($prompt); } @@ -614,7 +616,9 @@ public function testThrowIfNotSuccessfulFailure(): void $model = $this->createModel(); $this->expectException(ClientException::class); - $this->expectExceptionMessage('Client error (404): Request was rejected due to client-side issue - Not Found'); + $this->expectExceptionMessage( + 'Client error (404 Not Found): Request was rejected due to client-side issue - Not Found' + ); $model->exposeThrowIfNotSuccessful($response); } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php index 0287a4a5..46b3aac2 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php @@ -109,7 +109,9 @@ function (string $modelId) { ); $this->expectException(ClientException::class); - $this->expectExceptionMessage('Client error (400): Request was rejected due to client-side issue - Bad Request'); + $this->expectExceptionMessage( + 'Client error (400 Bad Request): Request was rejected due to client-side issue - Bad Request' + ); $directory->listModelMetadata(); } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index af833f6a..2535b63e 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -161,7 +161,9 @@ public function testGenerateTextResultApiFailure(): void $model = $this->createModel(); $this->expectException(ClientException::class); - $this->expectExceptionMessage('Client error (400): Request was rejected due to client-side issue - Bad Request'); + $this->expectExceptionMessage( + 'Client error (400 Bad Request): Request was rejected due to client-side issue - Bad Request' + ); $model->generateTextResult($prompt); } From 4e5529cc8fe9b7ff280d95bcd55e653637110e6e Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Mon, 29 Sep 2025 23:26:54 +0300 Subject: [PATCH 35/39] Remove unused RequestInterface import from ClientException --- src/Providers/Http/Exception/ClientException.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Providers/Http/Exception/ClientException.php b/src/Providers/Http/Exception/ClientException.php index 45a17352..6fad3dfa 100644 --- a/src/Providers/Http/Exception/ClientException.php +++ b/src/Providers/Http/Exception/ClientException.php @@ -4,7 +4,6 @@ namespace WordPress\AiClient\Providers\Http\Exception; -use Psr\Http\Message\RequestInterface; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; From 4cc060b2e43e5a949291c23084727e92ac85b488 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Mon, 29 Sep 2025 23:36:20 +0300 Subject: [PATCH 36/39] Remove ResponseException::fromBadResponse and enhance ServerException with status mapping --- .../Http/Exception/ResponseException.php | 29 ------------------- .../Http/Exception/ServerException.php | 18 ++++++++++-- src/Providers/Http/Util/ResponseUtil.php | 12 ++++---- .../Providers/Http/Util/ResponseUtilTest.php | 3 +- 4 files changed, 23 insertions(+), 39 deletions(-) diff --git a/src/Providers/Http/Exception/ResponseException.php b/src/Providers/Http/Exception/ResponseException.php index a10fd7dd..89c17153 100644 --- a/src/Providers/Http/Exception/ResponseException.php +++ b/src/Providers/Http/Exception/ResponseException.php @@ -5,8 +5,6 @@ namespace WordPress\AiClient\Providers\Http\Exception; use WordPress\AiClient\Common\Exception\RuntimeException; -use WordPress\AiClient\Providers\Http\DTO\Response; -use WordPress\AiClient\Providers\Http\Utilities\ErrorMessageExtractor; /** * Exception class for HTTP response errors. @@ -53,31 +51,4 @@ public static function fromInvalidData(string $apiName, string $message): self { return new self(sprintf('Unexpected %s API response: %s', $apiName, $message)); } - - /** - * Creates a ResponseException from a bad HTTP response. - * - * This method extracts error details from common API response formats - * and creates an exception with a descriptive message and status code. - * - * @since n.e.x.t - * - * @param Response $response The HTTP response that failed. - * @return self - */ - public static function fromBadResponse(Response $response): self - { - $errorMessage = sprintf( - 'Bad status code: %d.', - $response->getStatusCode() - ); - - // Extract error message from response data using centralized utility - $extractedError = ErrorMessageExtractor::extractFromResponseData($response->getData()); - if ($extractedError !== null) { - $errorMessage .= ' ' . $extractedError; - } - - return new self($errorMessage, $response->getStatusCode()); - } } diff --git a/src/Providers/Http/Exception/ServerException.php b/src/Providers/Http/Exception/ServerException.php index 0ecb8cac..bd6424bb 100644 --- a/src/Providers/Http/Exception/ServerException.php +++ b/src/Providers/Http/Exception/ServerException.php @@ -29,11 +29,23 @@ class ServerException extends RuntimeException * @param Response $response The HTTP response that failed. * @return self */ - public static function fromServerError(Response $response): self + public static function fromServerErrorResponse(Response $response): self { + $statusCode = $response->getStatusCode(); + $statusTexts = [ + 500 => 'Internal Server Error', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 507 => 'Insufficient Storage', + ]; + + $statusText = $statusTexts[$statusCode] ?? 'Server Error'; + $errorMessage = sprintf( - 'Server error (%d): Request failed due to server-side issue', - $response->getStatusCode() + 'Server error (%d %s): Request failed due to server-side issue', + $statusCode, + $statusText ); // Extract error message from response data using centralized utility diff --git a/src/Providers/Http/Util/ResponseUtil.php b/src/Providers/Http/Util/ResponseUtil.php index 4817b885..18177bed 100644 --- a/src/Providers/Http/Util/ResponseUtil.php +++ b/src/Providers/Http/Util/ResponseUtil.php @@ -7,7 +7,6 @@ use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Http\Exception\RedirectException; -use WordPress\AiClient\Providers\Http\Exception\ResponseException; use WordPress\AiClient\Providers\Http\Exception\ServerException; /** @@ -25,7 +24,7 @@ class ResponseUtil * - 3xx: RedirectException (redirect responses) * - 4xx: ClientException (client errors) * - 5xx: ServerException (server errors) - * - Other unsuccessful responses: ResponseException (malformed responses) + * - Other unsuccessful responses: RuntimeException (invalid status codes) * * @since 0.1.0 * @@ -33,7 +32,7 @@ class ResponseUtil * @throws RedirectException If the response indicates a redirect (3xx). * @throws ClientException If the response indicates a client error (4xx). * @throws ServerException If the response indicates a server error (5xx). - * @throws ResponseException If the response format is unexpected. + * @throws \RuntimeException If the response has an invalid status code. */ public static function throwIfNotSuccessful(Response $response): void { @@ -55,10 +54,11 @@ public static function throwIfNotSuccessful(Response $response): void // 5xx Server Errors if ($statusCode >= 500 && $statusCode < 600) { - throw ServerException::fromServerError($response); + throw ServerException::fromServerErrorResponse($response); } - // Other unsuccessful responses - should be extremely rare - throw ResponseException::fromBadResponse($response); + throw new \RuntimeException( + sprintf('Response returned invalid status code: %s', $response->getStatusCode()) + ); } } diff --git a/tests/unit/Providers/Http/Util/ResponseUtilTest.php b/tests/unit/Providers/Http/Util/ResponseUtilTest.php index e56831aa..307a4d2e 100644 --- a/tests/unit/Providers/Http/Util/ResponseUtilTest.php +++ b/tests/unit/Providers/Http/Util/ResponseUtilTest.php @@ -117,7 +117,8 @@ public function testThrowIfNotSuccessfulThrowsServerExceptionFor5xxErrors( $this->expectException(ServerException::class); $this->expectExceptionCode($statusCode); $this->expectExceptionMessageMatches( - "/^Server error \\({$statusCode}\\): Request failed due to server-side issue( - {$expectedMessagePart})?$/" + "/^Server error \\({$statusCode} [^)]+\\): Request failed due to " . + "server-side issue( - {$expectedMessagePart})?$/" ); ResponseUtil::throwIfNotSuccessful($response); From 436549f28e8ea355c11d17277c6cae781087889d Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Mon, 29 Sep 2025 23:47:38 +0300 Subject: [PATCH 37/39] Complete exception hierarchy with indexed field names and simplified fromMissingData method --- src/Providers/Http/Exception/ResponseException.php | 9 ++------- ...AbstractOpenAiCompatibleImageGenerationModel.php | 5 ++--- .../AbstractOpenAiCompatibleTextGenerationModel.php | 13 ++++++------- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/Providers/Http/Exception/ResponseException.php b/src/Providers/Http/Exception/ResponseException.php index 89c17153..65111ccb 100644 --- a/src/Providers/Http/Exception/ResponseException.php +++ b/src/Providers/Http/Exception/ResponseException.php @@ -24,16 +24,11 @@ class ResponseException extends RuntimeException * * @param string $apiName The name of the API/provider. * @param string $fieldName The field that was expected but missing. - * @param string $context Additional context about where the field was expected. * @return self */ - public static function fromMissingData(string $apiName, string $fieldName, string $context = ''): self + public static function fromMissingData(string $apiName, string $fieldName): self { - $message = sprintf('Unexpected %s API response: Missing the "%s" key', $apiName, $fieldName); - if ($context !== '') { - $message .= ' in ' . $context; - } - $message .= '.'; + $message = sprintf('Unexpected %s API response: Missing the "%s" key.', $apiName, $fieldName); return new self($message); } diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php index 6e760f9c..1bc0a17a 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php @@ -361,10 +361,9 @@ protected function parseResponseChoiceToCandidate( } elseif (isset($choiceData['b64_json']) && is_string($choiceData['b64_json'])) { $imageFile = new File($choiceData['b64_json'], $expectedMimeType); } else { - throw ResponseException::fromMissingData( + throw ResponseException::fromInvalidData( $this->providerMetadata()->getName(), - 'url or b64_json', - 'choice data' + 'Each choice 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 5bfb0009..5dfde4f6 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -566,7 +566,7 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera } $candidates = []; - foreach ($responseData['choices'] as $choiceData) { + foreach ($responseData['choices'] as $index => $choiceData) { if (!is_array($choiceData) || array_is_list($choiceData)) { throw ResponseException::fromInvalidData( $this->providerMetadata()->getName(), @@ -574,7 +574,7 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera ); } - $candidates[] = $this->parseResponseChoiceToCandidate($choiceData); + $candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $index); } $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; @@ -611,10 +611,11 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera * @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. * @return Candidate The parsed candidate. * @throws RuntimeException If the choice data is invalid. */ - protected function parseResponseChoiceToCandidate(array $choiceData): Candidate + protected function parseResponseChoiceToCandidate(array $choiceData, int $index): Candidate { if ( !isset($choiceData['message']) || @@ -623,16 +624,14 @@ protected function parseResponseChoiceToCandidate(array $choiceData): Candidate ) { throw ResponseException::fromMissingData( $this->providerMetadata()->getName(), - 'message', - 'choice data' + "choices[{$index}].message" ); } if (!isset($choiceData['finish_reason']) || !is_string($choiceData['finish_reason'])) { throw ResponseException::fromMissingData( $this->providerMetadata()->getName(), - 'finish_reason', - 'choice data' + "choices[{$index}].finish_reason" ); } From 8cde2412c6f9130c7ad64bf2f5a7e9117f1d95c5 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Mon, 29 Sep 2025 23:54:20 +0300 Subject: [PATCH 38/39] Fix test expectations for new exception hierarchy with indexed field names --- .../AbstractOpenAiCompatibleImageGenerationModelTest.php | 3 ++- .../AbstractOpenAiCompatibleTextGenerationModelTest.php | 6 +++--- .../MockOpenAiCompatibleTextGenerationModel.php | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php index 7e85f9e5..00af5963 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php @@ -826,7 +826,8 @@ public function testParseResponseChoiceToCandidateMissingUrlOrB64Json(): void $this->expectException(ResponseException::class); $this->expectExceptionMessage( - 'Unexpected TestProvider API response: Missing the "url or b64_json" key in choice data.' + 'Unexpected TestProvider API response: Each choice must contain either a url or b64_json key with a ' . + 'string value.' ); $model->exposeParseResponseChoiceToCandidate($choiceData); diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 2535b63e..104fcb71 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -1033,7 +1033,7 @@ public function testParseResponseChoiceToCandidateMissingMessage(): void $this->expectException(ResponseException::class); $this->expectExceptionMessage( - 'Unexpected TestProvider API response: Missing the "message" key in choice data.' + 'Unexpected TestProvider API response: Missing the "choices[0].message" key.' ); $model->exposeParseResponseChoiceToCandidate($choiceData); @@ -1054,7 +1054,7 @@ public function testParseResponseChoiceToCandidateInvalidMessageType(): void $this->expectException(ResponseException::class); $this->expectExceptionMessage( - 'Unexpected TestProvider API response: Missing the "message" key in choice data.' + 'Unexpected TestProvider API response: Missing the "choices[0].message" key.' ); $model->exposeParseResponseChoiceToCandidate($choiceData); @@ -1077,7 +1077,7 @@ public function testParseResponseChoiceToCandidateMissingFinishReason(): void $this->expectException(ResponseException::class); $this->expectExceptionMessage( - 'Unexpected TestProvider API response: Missing the "finish_reason" key in choice data.' + 'Unexpected TestProvider API response: Missing the "choices[0].finish_reason" key.' ); $model->exposeParseResponseChoiceToCandidate($choiceData); diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php b/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php index 0b7e8549..8e7b5e16 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php @@ -158,9 +158,9 @@ public function exposePrepareResponseFormatParam(?array $outputSchema): array return $this->prepareResponseFormatParam($outputSchema); } - public function exposeParseResponseChoiceToCandidate(array $choiceData): Candidate + public function exposeParseResponseChoiceToCandidate(array $choiceData, int $index = 0): Candidate { - return $this->parseResponseChoiceToCandidate($choiceData); + return $this->parseResponseChoiceToCandidate($choiceData, $index); } public function exposeParseResponseChoiceMessage(array $messageData): Message From ef9a3bb2749646de1499e9a8283bc445a93a9db7 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 30 Sep 2025 12:01:35 -0700 Subject: [PATCH 39/39] Enhance HTTP request exception message format. --- .../Http/Exception/ClientException.php | 17 +++++++++-------- .../Http/Exception/RedirectException.php | 15 ++++++++------- .../Http/Exception/ServerException.php | 15 ++++++++------- .../Providers/Http/Util/ResponseUtilTest.php | 8 +++----- ...OpenAiCompatibleImageGenerationModelTest.php | 12 ++++-------- ...enAiCompatibleModelMetadataDirectoryTest.php | 6 ++---- ...tOpenAiCompatibleTextGenerationModelTest.php | 6 ++---- 7 files changed, 36 insertions(+), 43 deletions(-) diff --git a/src/Providers/Http/Exception/ClientException.php b/src/Providers/Http/Exception/ClientException.php index 6fad3dfa..527cd0bd 100644 --- a/src/Providers/Http/Exception/ClientException.php +++ b/src/Providers/Http/Exception/ClientException.php @@ -69,13 +69,14 @@ public static function fromClientErrorResponse(Response $response): self 429 => 'Too Many Requests', ]; - $statusText = $statusTexts[$statusCode] ?? 'Client Error'; - - $errorMessage = sprintf( - 'Client error (%d %s): Request was rejected due to client-side issue', - $statusCode, - $statusText - ); + if (isset($statusTexts[$statusCode])) { + $errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode); + } else { + $errorMessage = sprintf( + 'Client error (%d): Request was rejected due to client-side issue', + $statusCode + ); + } // Extract error message from response data using centralized utility $extractedError = ErrorMessageExtractor::extractFromResponseData($response->getData()); @@ -83,6 +84,6 @@ public static function fromClientErrorResponse(Response $response): self $errorMessage .= ' - ' . $extractedError; } - return new self($errorMessage, $response->getStatusCode()); + return new self($errorMessage, $statusCode); } } diff --git a/src/Providers/Http/Exception/RedirectException.php b/src/Providers/Http/Exception/RedirectException.php index fa4d80ca..e91c64b9 100644 --- a/src/Providers/Http/Exception/RedirectException.php +++ b/src/Providers/Http/Exception/RedirectException.php @@ -42,13 +42,14 @@ public static function fromRedirectResponse(Response $response): self 308 => 'Permanent Redirect', ]; - $statusText = $statusTexts[$statusCode] ?? 'Redirect'; - - $errorMessage = sprintf( - 'Redirect response (%d %s): Request needs to be retried at a different location', - $statusCode, - $statusText - ); + if (isset($statusTexts[$statusCode])) { + $errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode); + } else { + $errorMessage = sprintf( + 'Redirect error (%d): Request needs to be retried at a different location', + $statusCode + ); + } // Try to extract the redirect location from headers $locationValues = $response->getHeader('Location'); diff --git a/src/Providers/Http/Exception/ServerException.php b/src/Providers/Http/Exception/ServerException.php index bd6424bb..37826cb1 100644 --- a/src/Providers/Http/Exception/ServerException.php +++ b/src/Providers/Http/Exception/ServerException.php @@ -40,13 +40,14 @@ public static function fromServerErrorResponse(Response $response): self 507 => 'Insufficient Storage', ]; - $statusText = $statusTexts[$statusCode] ?? 'Server Error'; - - $errorMessage = sprintf( - 'Server error (%d %s): Request failed due to server-side issue', - $statusCode, - $statusText - ); + if (isset($statusTexts[$statusCode])) { + $errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode); + } else { + $errorMessage = sprintf( + 'Server error (%d): Request was rejected due to server-side issue', + $statusCode + ); + } // Extract error message from response data using centralized utility $extractedError = ErrorMessageExtractor::extractFromResponseData($response->getData()); diff --git a/tests/unit/Providers/Http/Util/ResponseUtilTest.php b/tests/unit/Providers/Http/Util/ResponseUtilTest.php index 307a4d2e..bfe366d1 100644 --- a/tests/unit/Providers/Http/Util/ResponseUtilTest.php +++ b/tests/unit/Providers/Http/Util/ResponseUtilTest.php @@ -61,7 +61,7 @@ public function testThrowIfNotSuccessfulThrowsClientExceptionFor400BadRequest(): $this->expectException(ClientException::class); $this->expectExceptionCode(400); - $this->expectExceptionMessage('Client error (400 Bad Request): Request was rejected due to client-side issue'); + $this->expectExceptionMessage('Bad Request (400)'); ResponseUtil::throwIfNotSuccessful($response); } @@ -88,8 +88,7 @@ public function testThrowIfNotSuccessfulThrowsClientExceptionFor4xxErrors( $this->expectException(ClientException::class); $this->expectExceptionCode($statusCode); $this->expectExceptionMessageMatches( - "/^Client error \\({$statusCode} [^)]+\\): Request was rejected due to " . - "client-side issue( - {$expectedMessagePart})?$/" + "/^[A-Za-z ]+ \\({$statusCode}\\)( - {$expectedMessagePart})?$/" ); ResponseUtil::throwIfNotSuccessful($response); @@ -117,8 +116,7 @@ public function testThrowIfNotSuccessfulThrowsServerExceptionFor5xxErrors( $this->expectException(ServerException::class); $this->expectExceptionCode($statusCode); $this->expectExceptionMessageMatches( - "/^Server error \\({$statusCode} [^)]+\\): Request failed due to " . - "server-side issue( - {$expectedMessagePart})?$/" + "/^[A-Za-z ]+ \\({$statusCode}\\)( - {$expectedMessagePart})?$/" ); ResponseUtil::throwIfNotSuccessful($response); diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php index 00af5963..5b22121f 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php @@ -206,7 +206,7 @@ public function testGenerateImageResultSuccessWithBase64JsonOutput(): void public function testGenerateImageResultApiFailure(): void { $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('A tree')])]; - $response = new Response(400, [], '{"error": "Bad Request"}'); + $response = new Response(400, [], '{"error": "Invalid parameter."}'); $this->mockRequestAuthentication ->expects($this->once()) @@ -221,9 +221,7 @@ public function testGenerateImageResultApiFailure(): void $model = $this->createModel(); $this->expectException(ClientException::class); - $this->expectExceptionMessage( - 'Client error (400 Bad Request): Request was rejected due to client-side issue - Bad Request' - ); + $this->expectExceptionMessage('Bad Request (400) - Invalid parameter.'); $model->generateImageResult($prompt); } @@ -612,13 +610,11 @@ public function testThrowIfNotSuccessfulSuccess(): void */ public function testThrowIfNotSuccessfulFailure(): void { - $response = new Response(404, [], '{"error":"Not Found"}'); + $response = new Response(404, [], '{"error":"The resource does not exist."}'); $model = $this->createModel(); $this->expectException(ClientException::class); - $this->expectExceptionMessage( - 'Client error (404 Not Found): Request was rejected due to client-side issue - Not Found' - ); + $this->expectExceptionMessage('Not Found (404) - The resource does not exist.'); $model->exposeThrowIfNotSuccessful($response); } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php index 46b3aac2..4c9f8b44 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php @@ -88,7 +88,7 @@ public function createModelMetadataStub(string $modelId) */ public function testSendListModelsRequestFailure(): void { - $response = new Response(400, [], '{"error": "Bad Request"}'); + $response = new Response(400, [], '{"error": "Invalid parameter provided."}'); $this->mockRequestAuthentication ->expects($this->once()) @@ -109,9 +109,7 @@ function (string $modelId) { ); $this->expectException(ClientException::class); - $this->expectExceptionMessage( - 'Client error (400 Bad Request): Request was rejected due to client-side issue - Bad Request' - ); + $this->expectExceptionMessage('Bad Request (400) - Invalid parameter provided.'); $directory->listModelMetadata(); } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 104fcb71..1cc9596a 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -146,7 +146,7 @@ public function testGenerateTextResultSuccess(): void public function testGenerateTextResultApiFailure(): void { $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; - $response = new Response(400, [], '{"error": "Bad Request"}'); + $response = new Response(400, [], '{"error": "Invalid parameter."}'); $this->mockRequestAuthentication ->expects($this->once()) @@ -161,9 +161,7 @@ public function testGenerateTextResultApiFailure(): void $model = $this->createModel(); $this->expectException(ClientException::class); - $this->expectExceptionMessage( - 'Client error (400 Bad Request): Request was rejected due to client-side issue - Bad Request' - ); + $this->expectExceptionMessage('Bad Request (400) - Invalid parameter.'); $model->generateTextResult($prompt); }