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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions src/wp-includes/connectors.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
*/

use WordPress\AiClient\AiClient;
use WordPress\AiClient\Common\Contracts\CachesDataInterface;
use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability;
use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication;
use WordPress\AiClient\Providers\Http\Exception\ClientException;

/**
* Checks if a connector is registered.
Expand Down Expand Up @@ -581,6 +584,10 @@ function wp_connectors_get_application_password_credentials( array $auth ): arra
/**
* Checks whether an API key is valid for a given provider.
*
* A key is only reported as invalid when the provider rejects it. Failures that
* do not reflect on the key, such as network errors, server errors, or rate
* limiting, return null.
*
* @since 7.0.0
* @access private
*
Expand Down Expand Up @@ -610,7 +617,34 @@ function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ):
new ApiKeyRequestAuthentication( $key )
);

return $registry->isProviderConfigured( $provider_id );
$provider_class_name = $registry->getProviderClassName( $provider_id );

/*
* ListModelsApiBasedProviderAvailability::isConfigured() reports every failure as false,
* which makes an unreachable provider indistinguishable from a rejected key. Send the
* same list models request directly so that its failure can be told apart.
*/
if ( ! ( $provider_class_name::availability() instanceof ListModelsApiBasedProviderAvailability ) ) {
return $registry->isProviderConfigured( $provider_id );
}

$model_metadata_directory = $provider_class_name::modelMetadataDirectory();

// A cached model list says nothing about the key being checked.
if ( $model_metadata_directory instanceof CachesDataInterface ) {
$model_metadata_directory->invalidateCaches();
}

$model_metadata_directory->listModelMetadata();
return true;
} catch ( ClientException $e ) {
// The provider rejected the key, unless the request timed out or was rate limited.
if ( ! in_array( $e->getCode(), array( 408, 429 ), true ) ) {
return false;
}

wp_trigger_error( __FUNCTION__, $e->getMessage() );
return null;
} catch ( Exception $e ) {
wp_trigger_error( __FUNCTION__, $e->getMessage() );
return null;
Expand Down Expand Up @@ -683,8 +717,9 @@ function wp_connectors_sanitize_application_password_credentials( $value, string
* password field of default application-password credential objects.
*
* On POST or PUT requests, validates each updated AI provider API key before
* masking. If validation fails, the key is reverted to an empty string.
* Application password values are masked but not validated.
* masking. If the provider rejects the key, it is reverted to an empty string.
* A key that cannot be verified, for example because the provider is
* unreachable, is kept. Application password values are masked but not validated.
*
* @since 7.0.0
* @access private
Expand Down Expand Up @@ -738,7 +773,8 @@ function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_R
&& is_string( $value ) && '' !== $value
&& 'ai_provider' === $connector_data['type']
) {
if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) {
// Only discard a key the provider rejected, not one that could not be verified.
if ( false === _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) {
update_option( $setting_name, '' );
$data[ $setting_name ] = '';
continue;
Expand Down
182 changes: 182 additions & 0 deletions tests/phpunit/includes/wp-ai-client-mock-provider-trait.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@

use WordPress\AiClient\AiClient;
use WordPress\AiClient\Providers\AbstractProvider;
use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiProvider;
use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability;
use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Enums\ProviderTypeEnum;
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\Enums\RequestAuthenticationMethod;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleModelMetadataDirectory;

/**
* Mock provider availability with a controllable flag.
Expand Down Expand Up @@ -137,6 +144,111 @@ protected static function createModel(
}
}

/**
* Mock model metadata directory that lists models over HTTP.
*
* Built on the same base class as the official provider plugins, so requests go
* through the WP AI Client HTTP transporter and can be mocked with the
* `pre_http_request` filter.
*
* @since 7.2.0
*/
class Mock_Connectors_Test_Http_Model_Metadata_Directory extends AbstractOpenAiCompatibleModelMetadataDirectory {

/**
* Creates a request to the mock provider API.
*
* @param HttpMethodEnum $method The HTTP method.
* @param string $path The API path.
* @param array $headers The request headers.
* @param mixed $data The request data.
* @return Request The request.
*/
protected function createRequest( HttpMethodEnum $method, string $path, array $headers = array(), $data = null ): Request {
return new Request( $method, Mock_Connectors_Test_Http_Provider::url( $path ), $headers, $data );
}

/**
* Parses the list models response.
*
* @param Response $response The response.
* @return ModelMetadata[] The listed models.
*/
protected function parseResponseToModelMetadataList( Response $response ): array {
$data = $response->getData();
$models = array();
foreach ( $data['data'] ?? array() as $model ) {
$models[] = new ModelMetadata( $model['id'], $model['id'], array( CapabilityEnum::textGeneration() ), array() );
}
return $models;
}
}

/**
* Mock provider that checks its availability by listing models over HTTP,
* like the official provider plugins.
*
* @since 7.2.0
*/
class Mock_Connectors_Test_Http_Provider extends AbstractApiProvider {

/**
* Returns the base URL of the mock provider API.
*
* @return string
*/
protected static function baseUrl(): string {
return 'https://api.example.com/v1';
}

/**
* Creates the provider metadata.
*
* @return ProviderMetadata
*/
protected static function createProviderMetadata(): ProviderMetadata {
return new ProviderMetadata(
'mock-connectors-http-test',
'Mock Connectors HTTP Test',
ProviderTypeEnum::cloud(),
null,
RequestAuthenticationMethod::apiKey()
);
}

/**
* Creates the provider availability checker.
*
* @return ProviderAvailabilityInterface
*/
protected static function createProviderAvailability(): ProviderAvailabilityInterface {
return new ListModelsApiBasedProviderAvailability( static::modelMetadataDirectory() );
}

/**
* Creates the model metadata directory.
*
* @return ModelMetadataDirectoryInterface
*/
protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface {
return new Mock_Connectors_Test_Http_Model_Metadata_Directory();
}

/**
* Creates a model instance.
*
* @param ModelMetadata $model_metadata The model metadata.
* @param ProviderMetadata $provider_metadata The provider metadata.
* @throws \RuntimeException Always, as model creation is not needed for these tests.
*/
protected static function createModel(
ModelMetadata $model_metadata,
ProviderMetadata $provider_metadata
): ModelInterface {
throw new \RuntimeException( 'Not implemented.' );
}
}

/**
* Trait providing a mock AI provider for testing connector functions.
*
Expand Down Expand Up @@ -200,4 +312,74 @@ private static function unregister_mock_connector_setting(): void {
unregister_setting( 'connectors', $setting_name );
remove_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' );
}

/**
* How the HTTP mock provider's models endpoint responds.
*
* @var int|WP_Error HTTP status code, or a WP_Error to simulate a network failure.
*/
private $mock_models_endpoint_response = 200;

/**
* API keys sent to the HTTP mock provider's models endpoint, in request order.
*
* @var string[]
*/
private array $mock_models_endpoint_api_keys = array();

/**
* Registers the HTTP mock provider in the AI Client registry.
*
* Safe to call multiple times; skips registration if already done.
* Must be called from set_up_before_class() after parent::set_up_before_class().
*/
private static function register_mock_connectors_http_provider(): void {
$ai_registry = AiClient::defaultRegistry();
if ( ! $ai_registry->hasProvider( 'mock-connectors-http-test' ) ) {
$ai_registry->registerProvider( Mock_Connectors_Test_Http_Provider::class );
}
}

/**
* Sets how the HTTP mock provider's models endpoint responds.
*
* @param int|WP_Error $response HTTP status code, or a WP_Error to simulate a network failure.
*/
private function mock_models_endpoint_response( $response ): void {
$this->mock_models_endpoint_response = $response;
add_filter( 'pre_http_request', array( $this, 'filter_mock_models_endpoint_request' ), 10, 3 );
}

/**
* Responds to requests to the HTTP mock provider's models endpoint.
*
* @param false|array|WP_Error $response A preemptive return value of an HTTP request.
* @param array $parsed_args HTTP request arguments.
* @param string $url The request URL.
* @return false|array|WP_Error The mocked models endpoint response, otherwise the unchanged value.
*/
public function filter_mock_models_endpoint_request( $response, $parsed_args, $url ) {
if ( Mock_Connectors_Test_Http_Provider::url( 'models' ) !== $url ) {
return $response;
}

$this->mock_models_endpoint_api_keys[] = str_replace( 'Bearer ', '', $parsed_args['headers']['Authorization'] ?? '' );

if ( is_wp_error( $this->mock_models_endpoint_response ) ) {
return $this->mock_models_endpoint_response;
}

$status = $this->mock_models_endpoint_response;

return array(
'headers' => array( 'content-type' => 'application/json' ),
'body' => 200 === $status ? '{"data":[{"id":"mock-model"}]}' : '{"error":{"message":"Mock error."}}',
'response' => array(
'code' => $status,
'message' => get_status_header_desc( $status ),
),
'cookies' => array(),
'filename' => null,
);
}
}
Loading
Loading