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
14 changes: 10 additions & 4 deletions src/wp-includes/connectors.php
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,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 +614,7 @@ function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ):
new ApiKeyRequestAuthentication( $key )
);

return $registry->isProviderConfigured( $provider_id );
return $registry->verifyProviderCredentials( $provider_id );
} catch ( Exception $e ) {
wp_trigger_error( __FUNCTION__, $e->getMessage() );
return null;
Expand Down Expand Up @@ -683,8 +687,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 +743,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
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
namespace WordPress\AiClient\Providers\ApiBasedImplementation;

use Exception;
use WordPress\AiClient\Common\Contracts\CachesDataInterface;
use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
use WordPress\AiClient\Providers\Contracts\VerifiesCredentialsInterface;
use WordPress\AiClient\Providers\Http\Exception\ClientException;
/**
* Class to check availability for an API-based provider via a test request to the endpoint to list models.
*
Expand All @@ -15,7 +18,7 @@
*
* @since 0.1.0
*/
class ListModelsApiBasedProviderAvailability implements ProviderAvailabilityInterface
class ListModelsApiBasedProviderAvailability implements ProviderAvailabilityInterface, VerifiesCredentialsInterface
{
/**
* @var ModelMetadataDirectoryInterface The model metadata directory to use for checking availability.
Expand Down Expand Up @@ -49,4 +52,27 @@ public function isConfigured(): bool
return \false;
}
}
/**
* {@inheritDoc}
*
* The cached model list is invalidated first, as it may have been fetched with other credentials.
*
* @since n.e.x.t
*/
public function verifyCredentials(): bool
{
if ($this->modelMetadataDirectory instanceof CachesDataInterface) {
$this->modelMetadataDirectory->invalidateCaches();
}
try {
$this->modelMetadataDirectory->listModelMetadata();
} catch (ClientException $e) {
// A request timeout or rate limit says nothing about the credentials.
if (in_array($e->getCode(), [408, 429], \true)) {
throw $e;
}
return \false;
}
return \true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare (strict_types=1);
namespace WordPress\AiClient\Providers\Contracts;

/**
* Interface for provider availability checks that can verify the configured credentials.
*
* Unlike ProviderAvailabilityInterface::isConfigured(), which reports any failure as the provider not being
* configured, this tells credentials that the provider rejected apart from failures that do not reflect on them,
* such as network errors, server errors, or rate limiting.
*
* @since n.e.x.t
*/
interface VerifiesCredentialsInterface
{
/**
* Verifies the configured credentials by sending a request to the provider.
*
* @since n.e.x.t
*
* @return bool True if the provider accepted the credentials, false if it rejected them.
* @throws \Exception If the credentials could not be verified, for example because the provider could not be
* reached, responded with a server error, or rate limited the request.
*/
public function verifyCredentials(): bool;
}
26 changes: 26 additions & 0 deletions src/wp-includes/php-ai-client/src/Providers/ProviderRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Contracts\ProviderInterface;
use WordPress\AiClient\Providers\Contracts\ProviderWithOperationsHandlerInterface;
use WordPress\AiClient\Providers\Contracts\VerifiesCredentialsInterface;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\DTO\ProviderModelsMetadata;
use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface;
Expand Down Expand Up @@ -193,6 +194,31 @@ public function isProviderConfigured(string $idOrClassName): bool
return \false;
}
}
/**
* Verifies the credentials configured for a provider by sending a request to it.
*
* If the provider's availability check implements VerifiesCredentialsInterface, this only returns false when the
* provider rejected the credentials, and throws for failures that do not reflect on them, such as network errors.
* Otherwise, it returns the result of the availability check, like isProviderConfigured() does.
*
* @since n.e.x.t
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name.
* @return bool True if the provider accepted the credentials, false if it rejected them.
* @throws InvalidArgumentException If the provider is not registered.
* @throws \Exception If the credentials could not be verified.
*/
public function verifyProviderCredentials(string $idOrClassName): bool
{
$className = $this->resolveProviderClassName($idOrClassName);
// Use static method from ProviderInterface
/** @var class-string<ProviderInterface> $className */
$availability = $className::availability();
if ($availability instanceof VerifiesCredentialsInterface) {
return $availability->verifyCredentials();
}
return $availability->isConfigured();
}
/**
* Finds models across all available providers that support the given requirements.
*
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