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
6 changes: 6 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@ direction LR
+hasProvider(string $idOrClassName) bool
+getProviderClassName(string $id) string
+isProviderConfigured(string $idOrClassName) bool
+verifyProviderCredentials(string $idOrClassName) bool
+getProviderModel(string $idOrClassName, string $modelId, ModelConfig|array< string, mixed > $modelConfig) Model
+findProviderModelsMetadataForSupport(string $idOrClassName, ModelRequirements $modelRequirements) ModelMetadata[]
+findModelsMetadataForSupport(ModelRequirements $modelRequirements) ProviderModelMetadata[]
Expand Down Expand Up @@ -982,6 +983,7 @@ direction LR
+hasProvider(string $idOrClassName) bool
+getProviderClassName(string $id) string
+isProviderConfigured(string $idOrClassName) bool
+verifyProviderCredentials(string $idOrClassName) bool
+getProviderModel(string $idOrClassName, string $modelId, ModelConfig|array< string, mixed > $modelConfig) ModelInterface
+findProviderModelsMetadataForSupport(string $idOrClassName, ModelRequirements $modelRequirements) ModelMetadata[]
+findModelsMetadataForSupport(ModelRequirements $modelRequirements) AiProviderModelMetadata[]
Expand All @@ -997,6 +999,9 @@ direction LR
class ProviderAvailabilityInterface {
+isConfigured() bool
}
class VerifiesCredentialsInterface {
+verifyCredentials() bool
}
class ProviderInterface {
+metadata() ProviderMetadata$
+model(string $modelId, ModelConfig|array< string, mixed > $modelConfig) ModelInterface$
Expand Down Expand Up @@ -1268,6 +1273,7 @@ direction LR
<<interface>> ProviderInterface
<<interface>> ModelInterface
<<interface>> ProviderAvailabilityInterface
<<interface>> VerifiesCredentialsInterface
<<interface>> ModelMetadataDirectoryInterface
<<interface>> ProviderOperationsHandlerInterface
<<interface>> ProviderWithOperationsHandlerInterface
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,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 @@ -17,7 +20,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 @@ -53,4 +56,30 @@ 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;
}
}
28 changes: 28 additions & 0 deletions src/Providers/Contracts/VerifiesCredentialsInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?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;
}
30 changes: 30 additions & 0 deletions src/Providers/ProviderRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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 @@ -230,6 +231,35 @@ public function isProviderConfigured(string $idOrClassName): bool
}
}

/**
* 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
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@

use Exception;
use PHPUnit\Framework\TestCase;
use WordPress\AiClient\Common\Contracts\CachesDataInterface;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability;
use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Exception\ClientException;
use WordPress\AiClient\Providers\Http\Exception\NetworkException;
use WordPress\AiClient\Providers\Http\Exception\ServerException;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;

/**
* @covers \WordPress\AiClient\Providers\ApiBasedImplementation\ListModelsApiBasedProviderAvailability
Expand Down Expand Up @@ -59,4 +66,139 @@ public function testIsConfiguredReturnsFalseOnException(): void

$this->assertFalse($availability->isConfigured());
}

/**
* Tests verifyCredentials() method when listing models succeeds.
*
* @return void
*/
public function testVerifyCredentialsReturnsTrueOnSuccess(): void
{
$this->modelMetadataDirectory
->expects($this->once())
->method('listModelMetadata')
->willReturn([]);

$availability = new ListModelsApiBasedProviderAvailability($this->modelMetadataDirectory);

$this->assertTrue($availability->verifyCredentials());
}

/**
* Tests verifyCredentials() method when the provider rejects the credentials.
*
* @dataProvider rejectedCredentialsStatusCodeProvider
*
* @param int $statusCode The HTTP status code of the response.
* @return void
*/
public function testVerifyCredentialsReturnsFalseWhenCredentialsAreRejected(int $statusCode): void
{
$this->modelMetadataDirectory
->expects($this->once())
->method('listModelMetadata')
->willThrowException(ClientException::fromClientErrorResponse(new Response($statusCode, [])));

$availability = new ListModelsApiBasedProviderAvailability($this->modelMetadataDirectory);

$this->assertFalse($availability->verifyCredentials());
}

/**
* Provides status codes of responses that reject the credentials.
*
* @return array<string, array{int}>
*/
public function rejectedCredentialsStatusCodeProvider(): array
{
return [
'400 Bad Request' => [400],
'401 Unauthorized' => [401],
'403 Forbidden' => [403],
];
}

/**
* Tests verifyCredentials() method when the credentials cannot be verified.
*
* @dataProvider unverifiableCredentialsExceptionProvider
*
* @param Exception $exception The exception thrown when listing models.
* @return void
*/
public function testVerifyCredentialsThrowsWhenCredentialsCannotBeVerified(Exception $exception): void
{
$this->modelMetadataDirectory
->expects($this->once())
->method('listModelMetadata')
->willThrowException($exception);

$availability = new ListModelsApiBasedProviderAvailability($this->modelMetadataDirectory);

$this->expectExceptionObject($exception);

$availability->verifyCredentials();
}

/**
* Provides exceptions that do not reflect on the credentials.
*
* @return array<string, array{Exception}>
*/
public function unverifiableCredentialsExceptionProvider(): array
{
return [
'network error' => [new NetworkException('Connection timed out.')],
'408 Request Timeout' => [ClientException::fromClientErrorResponse(new Response(408, []))],
'429 Too Many Requests' => [ClientException::fromClientErrorResponse(new Response(429, []))],
'500 Internal Server Error' => [ServerException::fromServerErrorResponse(new Response(500, []))],
'503 Service Unavailable' => [ServerException::fromServerErrorResponse(new Response(503, []))],
];
}

/**
* Tests that verifyCredentials() does not rely on a cached model list, while isConfigured() does.
*
* @return void
*/
public function testVerifyCredentialsInvalidatesCachedModelsFirst(): void
{
$modelMetadataDirectory = new class implements ModelMetadataDirectoryInterface, CachesDataInterface {
/**
* @var list<string> The methods called on this directory, in order.
*/
public array $calls = [];

public function listModelMetadata(): array
{
$this->calls[] = 'listModelMetadata';
return [];
}

public function hasModelMetadata(string $modelId): bool
{
return false;
}

public function getModelMetadata(string $modelId): ModelMetadata
{
throw new InvalidArgumentException('No models available.');
}

public function invalidateCaches(): void
{
$this->calls[] = 'invalidateCaches';
}
};

$availability = new ListModelsApiBasedProviderAvailability($modelMetadataDirectory);

$availability->isConfigured();
$this->assertSame(['listModelMetadata'], $modelMetadataDirectory->calls);

$modelMetadataDirectory->calls = [];

$availability->verifyCredentials();
$this->assertSame(['invalidateCaches', 'listModelMetadata'], $modelMetadataDirectory->calls);
}
}
49 changes: 49 additions & 0 deletions tests/unit/Providers/ProviderRegistryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use WordPress\AiClient\Providers\Contracts\VerifiesCredentialsInterface;
use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface;
use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication;
use WordPress\AiClient\Providers\Http\DTO\Request;
Expand Down Expand Up @@ -148,6 +149,54 @@ public function testIsProviderConfiguredWithUnregisteredProvider(): void
$this->assertFalse($this->registry->isProviderConfigured('nonexistent'));
}

/**
* Tests verifyProviderCredentials with an availability check that can verify credentials.
*
* @return void
*/
public function testVerifyProviderCredentialsUsesCredentialsVerification(): void
{
MockProvider::setAvailability(
new class extends MockProviderAvailability implements VerifiesCredentialsInterface {
public function verifyCredentials(): bool
{
return false;
}
}
);
$this->registry->registerProvider(MockProvider::class);

// The availability check reports the provider as configured, but the credentials verification decides.
$this->assertTrue($this->registry->isProviderConfigured('mock'));
$this->assertFalse($this->registry->verifyProviderCredentials('mock'));
}

/**
* Tests verifyProviderCredentials with an availability check that cannot verify credentials.
*
* @return void
*/
public function testVerifyProviderCredentialsFallsBackToAvailabilityCheck(): void
{
MockProvider::setAvailability(new MockProviderAvailability(false));
$this->registry->registerProvider(MockProvider::class);

$this->assertFalse($this->registry->verifyProviderCredentials('mock'));
}

/**
* Tests verifyProviderCredentials with unregistered provider.
*
* @return void
*/
public function testVerifyProviderCredentialsWithUnregisteredProvider(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Provider not registered: nonexistent');

$this->registry->verifyProviderCredentials('nonexistent');
}

/**
* Tests findModelsMetadataForSupport with no registered providers.
*
Expand Down
Loading