From c6f3f928afecaca46ddb5b1ffe07716c775bd288 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 30 Sep 2026 20:17:45 +0100 Subject: [PATCH] Add provider credentials verification ListModelsApiBasedProviderAvailability::isConfigured() reports every failure as false and relies on the cached model list, so it cannot tell rejected credentials apart from a network error, and it keeps reporting success for other credentials while the list is cached. Add VerifiesCredentialsInterface and ProviderRegistry::verifyProviderCredentials(). The list models availability check implements the interface: it invalidates the cached model list, returns false only when the provider rejects the request, and throws for network errors, server errors, timeouts and rate limits. --- docs/ARCHITECTURE.md | 6 + ...ListModelsApiBasedProviderAvailability.php | 31 +++- .../VerifiesCredentialsInterface.php | 28 ++++ src/Providers/ProviderRegistry.php | 30 ++++ ...ModelsApiBasedProviderAvailabilityTest.php | 142 ++++++++++++++++++ tests/unit/Providers/ProviderRegistryTest.php | 49 ++++++ 6 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 src/Providers/Contracts/VerifiesCredentialsInterface.php diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f6635276..af0607f3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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[] @@ -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[] @@ -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$ @@ -1268,6 +1273,7 @@ direction LR <> ProviderInterface <> ModelInterface <> ProviderAvailabilityInterface + <> VerifiesCredentialsInterface <> ModelMetadataDirectoryInterface <> ProviderOperationsHandlerInterface <> ProviderWithOperationsHandlerInterface diff --git a/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php b/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php index 0ee1daf7..35d5c2de 100644 --- a/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php +++ b/src/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailability.php @@ -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. @@ -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. @@ -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; + } } diff --git a/src/Providers/Contracts/VerifiesCredentialsInterface.php b/src/Providers/Contracts/VerifiesCredentialsInterface.php new file mode 100644 index 00000000..d99e7e37 --- /dev/null +++ b/src/Providers/Contracts/VerifiesCredentialsInterface.php @@ -0,0 +1,28 @@ + $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 $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. * diff --git a/tests/unit/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailabilityTest.php b/tests/unit/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailabilityTest.php index 3f5ed1bd..bfbbec46 100644 --- a/tests/unit/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailabilityTest.php +++ b/tests/unit/Providers/ApiBasedImplementation/ListModelsApiBasedProviderAvailabilityTest.php @@ -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 @@ -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 + */ + 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 + */ + 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 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); + } } diff --git a/tests/unit/Providers/ProviderRegistryTest.php b/tests/unit/Providers/ProviderRegistryTest.php index ff8197e6..e8244c4e 100644 --- a/tests/unit/Providers/ProviderRegistryTest.php +++ b/tests/unit/Providers/ProviderRegistryTest.php @@ -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; @@ -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. *