diff --git a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php index da7858dd76555..39f2338feb7b5 100644 --- a/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php +++ b/src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php @@ -185,9 +185,9 @@ class WP_AI_Client_Prompt_Builder { public function __construct( ProviderRegistry $registry, $prompt = null ) { try { $this->builder = new PromptBuilder( $registry, $prompt, AiClient::getEventDispatcher() ); - } catch ( Exception $e ) { + } catch ( Throwable $e ) { $this->builder = new PromptBuilder( $registry, null, AiClient::getEventDispatcher() ); - $this->error = $this->exception_to_wp_error( $e ); + $this->error = $this->throwable_to_wp_error( $e ); } $default_timeout = 30.0; @@ -358,8 +358,8 @@ public function __call( string $name, array $arguments ) { } return $result; - } catch ( Exception $e ) { - $this->error = $this->exception_to_wp_error( $e ); + } catch ( Throwable $e ) { + $this->error = $this->throwable_to_wp_error( $e ); if ( self::is_generating_method( $name ) ) { return $this->error; @@ -369,33 +369,33 @@ public function __call( string $name, array $arguments ) { } /** - * Converts an exception into a WP_Error with a structured error code and message. + * Converts a throwable into a WP_Error with a structured error code and message. * - * This method maps different exception types to specific WP_Error codes and HTTP status codes. + * This method maps different throwable types to specific WP_Error codes and HTTP status codes. * The presence of the status codes means these WP_Error objects can be easily used in REST API responses * or other contexts where HTTP semantics are relevant. * * @since 7.0.0 * - * @param Exception $e The exception to convert. + * @param Throwable $throwable The throwable to convert. * @return WP_Error The resulting WP_Error object. */ - private function exception_to_wp_error( Exception $e ): WP_Error { - if ( $e instanceof NetworkException ) { + private function throwable_to_wp_error( Throwable $throwable ): WP_Error { + if ( $throwable instanceof NetworkException ) { $error_code = 'prompt_network_error'; $status_code = 503; - } elseif ( $e instanceof ClientException ) { + } elseif ( $throwable instanceof ClientException ) { // `ClientException` uses HTTP status codes as exception codes, so we can rely on them. $error_code = 'prompt_client_error'; - $status_code = $e->getCode() ? $e->getCode() : 400; - } elseif ( $e instanceof ServerException ) { + $status_code = $throwable->getCode() ? $throwable->getCode() : 400; + } elseif ( $throwable instanceof ServerException ) { // `ServerException` uses HTTP status codes as exception codes, so we can rely on them. $error_code = 'prompt_upstream_server_error'; - $status_code = $e->getCode() ? $e->getCode() : 500; - } elseif ( $e instanceof TokenLimitReachedException ) { + $status_code = $throwable->getCode() ? $throwable->getCode() : 500; + } elseif ( $throwable instanceof TokenLimitReachedException ) { $error_code = 'prompt_token_limit_reached'; $status_code = 400; - } elseif ( $e instanceof InvalidArgumentException ) { + } elseif ( $throwable instanceof InvalidArgumentException ) { $error_code = 'prompt_invalid_argument'; $status_code = 400; } else { @@ -405,10 +405,10 @@ private function exception_to_wp_error( Exception $e ): WP_Error { return new WP_Error( $error_code, - $e->getMessage(), + $throwable->getMessage(), array( 'status' => $status_code, - 'exception_class' => get_class( $e ), + 'exception_class' => get_class( $throwable ), ) ); } diff --git a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php index e758a6868aa42..31ab70485a565 100644 --- a/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php +++ b/tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php @@ -2743,18 +2743,18 @@ public function test_exception_in_chained_method_caught_and_returned_by_terminat } /** - * Invokes the private exception_to_wp_error method via reflection. + * Invokes the private throwable_to_wp_error method via reflection. * * @param WP_AI_Client_Prompt_Builder $builder The builder instance. - * @param Exception $exception The exception to convert. + * @param Throwable $throwable The throwable to convert. * @return WP_Error The resulting WP_Error. */ - private function invoke_exception_to_wp_error( WP_AI_Client_Prompt_Builder $builder, Exception $exception ): WP_Error { + private function invoke_throwable_to_wp_error( WP_AI_Client_Prompt_Builder $builder, Throwable $throwable ): WP_Error { $reflection = new ReflectionClass( WP_AI_Client_Prompt_Builder::class ); - $method = $reflection->getMethod( 'exception_to_wp_error' ); + $method = $reflection->getMethod( 'throwable_to_wp_error' ); self::set_accessible( $method ); - return $method->invoke( $builder, $exception ); + return $method->invoke( $builder, $throwable ); } /** @@ -2764,7 +2764,7 @@ private function invoke_exception_to_wp_error( WP_AI_Client_Prompt_Builder $buil */ public function test_exception_to_wp_error_network_exception() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new NetworkException( 'Connection timed out' ) ); @@ -2782,7 +2782,7 @@ public function test_exception_to_wp_error_network_exception() { */ public function test_exception_to_wp_error_client_exception_with_code() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new ClientException( 'Unauthorized', 401 ) ); @@ -2800,7 +2800,7 @@ public function test_exception_to_wp_error_client_exception_with_code() { */ public function test_exception_to_wp_error_client_exception_without_code() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new ClientException( 'Bad request' ) ); @@ -2817,7 +2817,7 @@ public function test_exception_to_wp_error_client_exception_without_code() { */ public function test_exception_to_wp_error_server_exception_with_code() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new ServerException( 'Bad gateway', 502 ) ); @@ -2835,7 +2835,7 @@ public function test_exception_to_wp_error_server_exception_with_code() { */ public function test_exception_to_wp_error_server_exception_without_code() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new ServerException( 'Internal server error' ) ); @@ -2852,7 +2852,7 @@ public function test_exception_to_wp_error_server_exception_without_code() { */ public function test_exception_to_wp_error_token_limit_reached_exception() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new TokenLimitReachedException( 'Token limit exceeded', 4096 ) ); @@ -2870,7 +2870,7 @@ public function test_exception_to_wp_error_token_limit_reached_exception() { */ public function test_exception_to_wp_error_invalid_argument_exception() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new AiClientInvalidArgumentException( 'Invalid model parameter' ) ); @@ -2888,7 +2888,7 @@ public function test_exception_to_wp_error_invalid_argument_exception() { */ public function test_exception_to_wp_error_generic_exception() { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( + $error = $this->invoke_throwable_to_wp_error( $builder, new Exception( 'Something went wrong' ) ); @@ -2899,6 +2899,51 @@ public function test_exception_to_wp_error_generic_exception() { $this->assertSame( 'Exception', $error->get_error_data()['exception_class'] ); } + /** + * Tests exception_to_wp_error maps an Error (e.g. TypeError) to a generic builder error. + * + * A TypeError extends Error, not Exception, so this guards against the + * conversion only accepting Exception instances. + * + * @ticket 65505 + */ + public function test_exception_to_wp_error_type_error() { + $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); + $error = $this->invoke_throwable_to_wp_error( + $builder, + new TypeError( 'Argument must be of type float' ) + ); + + $this->assertSame( 'prompt_builder_error', $error->get_error_code() ); + $this->assertSame( 'Argument must be of type float', $error->get_error_message() ); + $this->assertSame( 500, $error->get_error_data()['status'] ); + $this->assertSame( 'TypeError', $error->get_error_data()['exception_class'] ); + } + + /** + * Tests that a TypeError thrown by the wrapped SDK is caught and returned as a WP_Error. + * + * Passing an argument of the wrong type to a strict-typed SDK method throws a + * TypeError (which extends Error, not Exception). The builder must catch it and + * place itself in an error state instead of letting it fatal the request. + * + * @ticket 65505 + */ + public function test_call_catches_type_error_from_invalid_argument_type() { + $builder = new WP_AI_Client_Prompt_Builder( $this->registry, 'Test prompt' ); + + // usingTemperature() expects a float; an array can never be coerced and throws a TypeError. + $result = $builder->using_temperature( array( 0.7 ) ); + + // The builder is returned (fluent interface preserved), now in an error state. + $this->assertInstanceOf( WP_AI_Client_Prompt_Builder::class, $result ); + + // A generating method now surfaces the stored WP_Error rather than fataling. + $error = $result->generate_text(); + $this->assertWPError( $error ); + $this->assertSame( 'prompt_builder_error', $error->get_error_code() ); + } + /** * Tests exception_to_wp_error always includes status and exception_class in error data. * @@ -2910,7 +2955,7 @@ public function test_exception_to_wp_error_generic_exception() { */ public function test_exception_to_wp_error_error_data_structure( Exception $exception ) { $builder = new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry() ); - $error = $this->invoke_exception_to_wp_error( $builder, $exception ); + $error = $this->invoke_throwable_to_wp_error( $builder, $exception ); $data = $error->get_error_data(); $this->assertIsArray( $data );