Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 17 additions & 17 deletions src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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 ),
)
);
}
Expand Down
73 changes: 59 additions & 14 deletions tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}

/**
Expand All @@ -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' )
);
Expand All @@ -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 )
);
Expand All @@ -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' )
);
Expand All @@ -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 )
);
Expand All @@ -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' )
);
Expand All @@ -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 )
);
Expand All @@ -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' )
);
Expand All @@ -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' )
);
Expand All @@ -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.
*
Expand All @@ -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 );
Expand Down
Loading