diff --git a/src/ProviderImplementations/Anthropic/AnthropicProvider.php b/src/ProviderImplementations/Anthropic/AnthropicProvider.php index 3d817769..4d21b967 100644 --- a/src/ProviderImplementations/Anthropic/AnthropicProvider.php +++ b/src/ProviderImplementations/Anthropic/AnthropicProvider.php @@ -11,6 +11,7 @@ use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; +use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -63,7 +64,8 @@ protected static function createProviderMetadata(): ProviderMetadata 'anthropic', 'Anthropic', ProviderTypeEnum::cloud(), - 'https://console.anthropic.com/settings/keys' + 'https://console.anthropic.com/settings/keys', + RequestAuthenticationMethod::apiKey() ); } diff --git a/src/ProviderImplementations/Google/GoogleProvider.php b/src/ProviderImplementations/Google/GoogleProvider.php index 72ccd632..831c9fb2 100644 --- a/src/ProviderImplementations/Google/GoogleProvider.php +++ b/src/ProviderImplementations/Google/GoogleProvider.php @@ -11,6 +11,7 @@ use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; +use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -66,7 +67,8 @@ protected static function createProviderMetadata(): ProviderMetadata 'google', 'Google', ProviderTypeEnum::cloud(), - 'https://aistudio.google.com/app/api-keys' + 'https://aistudio.google.com/app/api-keys', + RequestAuthenticationMethod::apiKey() ); } diff --git a/src/ProviderImplementations/OpenAi/OpenAiProvider.php b/src/ProviderImplementations/OpenAi/OpenAiProvider.php index 412e0e35..11974de1 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiProvider.php +++ b/src/ProviderImplementations/OpenAi/OpenAiProvider.php @@ -11,6 +11,7 @@ use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; +use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -72,7 +73,8 @@ protected static function createProviderMetadata(): ProviderMetadata 'openai', 'OpenAI', ProviderTypeEnum::cloud(), - 'https://platform.openai.com/api-keys' + 'https://platform.openai.com/api-keys', + RequestAuthenticationMethod::apiKey() ); } diff --git a/src/Providers/DTO/ProviderMetadata.php b/src/Providers/DTO/ProviderMetadata.php index a1d4fe54..ec5e6fc5 100644 --- a/src/Providers/DTO/ProviderMetadata.php +++ b/src/Providers/DTO/ProviderMetadata.php @@ -6,6 +6,7 @@ use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; +use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; /** * Represents metadata about an AI provider. @@ -19,7 +20,8 @@ * id: string, * name: string, * type: string, - * credentialsUrl?: ?string + * credentialsUrl?: ?string, + * authenticationMethod?: ?string * } * * @extends AbstractDataTransferObject @@ -30,6 +32,7 @@ class ProviderMetadata extends AbstractDataTransferObject public const KEY_NAME = 'name'; public const KEY_TYPE = 'type'; public const KEY_CREDENTIALS_URL = 'credentialsUrl'; + public const KEY_AUTHENTICATION_METHOD = 'authenticationMethod'; /** * @var string The provider's unique identifier. @@ -51,6 +54,11 @@ class ProviderMetadata extends AbstractDataTransferObject */ protected ?string $credentialsUrl; + /** + * @var RequestAuthenticationMethod|null The authentication method. + */ + protected ?RequestAuthenticationMethod $authenticationMethod; + /** * Constructor. * @@ -60,13 +68,20 @@ class ProviderMetadata extends AbstractDataTransferObject * @param string $name The provider's display name. * @param ProviderTypeEnum $type The provider type. * @param string|null $credentialsUrl The URL where users can get credentials. + * @param RequestAuthenticationMethod|null $authenticationMethod The authentication method. */ - public function __construct(string $id, string $name, ProviderTypeEnum $type, ?string $credentialsUrl = null) - { + public function __construct( + string $id, + string $name, + ProviderTypeEnum $type, + ?string $credentialsUrl = null, + ?RequestAuthenticationMethod $authenticationMethod = null + ) { $this->id = $id; $this->name = $name; $this->type = $type; $this->credentialsUrl = $credentialsUrl; + $this->authenticationMethod = $authenticationMethod; } /** @@ -117,6 +132,18 @@ public function getCredentialsUrl(): ?string return $this->credentialsUrl; } + /** + * Gets the authentication method. + * + * @since n.e.x.t + * + * @return RequestAuthenticationMethod|null The authentication method. + */ + public function getAuthenticationMethod(): ?RequestAuthenticationMethod + { + return $this->authenticationMethod; + } + /** * {@inheritDoc} * @@ -144,6 +171,11 @@ public static function getJsonSchema(): array 'type' => 'string', 'description' => 'The URL where users can get credentials.', ], + self::KEY_AUTHENTICATION_METHOD => [ + 'type' => ['string', 'null'], + 'enum' => array_merge(RequestAuthenticationMethod::getValues(), [null]), + 'description' => 'The authentication method.', + ], ], 'required' => [self::KEY_ID, self::KEY_NAME, self::KEY_TYPE], ]; @@ -163,6 +195,7 @@ public function toArray(): array self::KEY_NAME => $this->name, self::KEY_TYPE => $this->type->value, self::KEY_CREDENTIALS_URL => $this->credentialsUrl, + self::KEY_AUTHENTICATION_METHOD => $this->authenticationMethod ? $this->authenticationMethod->value : null, ]; } @@ -179,7 +212,10 @@ public static function fromArray(array $array): self $array[self::KEY_ID], $array[self::KEY_NAME], ProviderTypeEnum::from($array[self::KEY_TYPE]), - $array[self::KEY_CREDENTIALS_URL] ?? null + $array[self::KEY_CREDENTIALS_URL] ?? null, + isset($array[self::KEY_AUTHENTICATION_METHOD]) + ? RequestAuthenticationMethod::from($array[self::KEY_AUTHENTICATION_METHOD]) + : null ); } } diff --git a/src/Providers/Http/Enums/RequestAuthenticationMethod.php b/src/Providers/Http/Enums/RequestAuthenticationMethod.php new file mode 100644 index 00000000..27de753f --- /dev/null +++ b/src/Providers/Http/Enums/RequestAuthenticationMethod.php @@ -0,0 +1,42 @@ + The implementation class. + * + * @phpstan-ignore missingType.generics + */ + public function getImplementationClass(): string + { + // At the moment, this is the only supported method. + // Once more methods are available, add conditionals here for each method. + return ApiKeyRequestAuthentication::class; + } +} diff --git a/src/Providers/ProviderRegistry.php b/src/Providers/ProviderRegistry.php index f937a44b..e64a5e8f 100644 --- a/src/Providers/ProviderRegistry.php +++ b/src/Providers/ProviderRegistry.php @@ -14,7 +14,6 @@ use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface; -use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; @@ -443,11 +442,36 @@ private function setHttpTransporterForProvider( * * @param class-string $className The provider class name. * @param RequestAuthenticationInterface $requestAuthentication The authentication instance. + * + * @throws InvalidArgumentException If the authentication instance is not of the expected type. */ private function setRequestAuthenticationForProvider( string $className, RequestAuthenticationInterface $requestAuthentication ): void { + $authenticationMethod = $className::metadata()->getAuthenticationMethod(); + if ($authenticationMethod === null) { + throw new InvalidArgumentException( + sprintf( + 'Provider %s does not expect any authentication, but got %s.', + $className, + get_class($requestAuthentication) + ) + ); + } + + $expectedClass = $authenticationMethod->getImplementationClass(); + if (!$requestAuthentication instanceof $expectedClass) { + throw new InvalidArgumentException( + sprintf( + 'Provider %s expects authentication of type %s, but got %s.', + $className, + $expectedClass, + get_class($requestAuthentication) + ) + ); + } + $availability = $className::availability(); if ($availability instanceof WithRequestAuthenticationInterface) { $availability->setRequestAuthentication($requestAuthentication); @@ -478,14 +502,19 @@ private function setRequestAuthenticationForProvider( private function createDefaultProviderRequestAuthentication( string $className ): ?RequestAuthenticationInterface { - $providerId = $className::metadata()->getId(); - - /* - * For now, we assume API key authentication is used by default. - * In the future, this could be made more flexible by allowing the provider to express a specific type of - * request authentication to use. - */ - $authenticationClass = ApiKeyRequestAuthentication::class; + $providerMetadata = $className::metadata(); + $providerId = $providerMetadata->getId(); + $authenticationMethod = $providerMetadata->getAuthenticationMethod(); + + if ($authenticationMethod === null) { + return null; + } + + $authenticationClass = $authenticationMethod->getImplementationClass(); + if ($authenticationClass === null) { + return null; + } + $authenticationSchema = $authenticationClass::getJsonSchema(); // Iterate over all JSON schema object properties to try to determine the necessary authentication data. @@ -535,6 +564,8 @@ private function createDefaultProviderRequestAuthentication( } } + /** @var RequestAuthenticationInterface */ + /** @var array $authenticationData */ return $authenticationClass::fromArray($authenticationData); } diff --git a/tests/mocks/MockNoAuthProvider.php b/tests/mocks/MockNoAuthProvider.php new file mode 100644 index 00000000..8100b174 --- /dev/null +++ b/tests/mocks/MockNoAuthProvider.php @@ -0,0 +1,52 @@ +token = $token; - } - - /** - * {@inheritDoc} - */ - public function authenticateRequest(Request $request): Request - { - return $request->withHeader('X-Mock-Auth', $this->token); - } - - /** - * {@inheritDoc} - */ - public static function getJsonSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [], - 'required' => [], - ]; - } -} diff --git a/tests/unit/Providers/DTO/ProviderMetadataTest.php b/tests/unit/Providers/DTO/ProviderMetadataTest.php index 3b0b18d2..7eee7956 100644 --- a/tests/unit/Providers/DTO/ProviderMetadataTest.php +++ b/tests/unit/Providers/DTO/ProviderMetadataTest.php @@ -134,7 +134,8 @@ public function testToArray(): void $this->assertEquals('Anthropic', $array[ProviderMetadata::KEY_NAME]); $this->assertEquals('cloud', $array[ProviderMetadata::KEY_TYPE]); $this->assertNull($array[ProviderMetadata::KEY_CREDENTIALS_URL]); - $this->assertCount(4, $array); + $this->assertNull($array[ProviderMetadata::KEY_AUTHENTICATION_METHOD]); + $this->assertCount(5, $array); } /** diff --git a/tests/unit/Providers/ProviderRegistryTest.php b/tests/unit/Providers/ProviderRegistryTest.php index 6c08fbae..b3f52178 100644 --- a/tests/unit/Providers/ProviderRegistryTest.php +++ b/tests/unit/Providers/ProviderRegistryTest.php @@ -6,7 +6,9 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; +use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; @@ -15,9 +17,9 @@ use WordPress\AiClient\Tests\mocks\MockHttpTransporter; use WordPress\AiClient\Tests\mocks\MockModel; use WordPress\AiClient\Tests\mocks\MockModelMetadataDirectory; +use WordPress\AiClient\Tests\mocks\MockNoAuthProvider; use WordPress\AiClient\Tests\mocks\MockProvider; use WordPress\AiClient\Tests\mocks\MockProviderAvailability; -use WordPress\AiClient\Tests\mocks\MockRequestAuthentication; /** * @covers \WordPress\AiClient\Providers\ProviderRegistry @@ -310,7 +312,7 @@ public function testSetProviderRequestAuthenticationHooksUpToProviders(): void $mockTransporter = new MockHttpTransporter(); // Add this line $this->registry->setHttpTransporter($mockTransporter); // Add this line - $mockAuth = new MockRequestAuthentication('custom_token'); + $mockAuth = new ApiKeyRequestAuthentication('custom_token'); $mockAvailability = new MockProviderAvailability(); $mockModelMetadataDirectory = new MockModelMetadataDirectory([ 'mock-text-model' => new ModelMetadata( @@ -352,7 +354,7 @@ public function testSetProviderRequestAuthenticationHooksUpToProviders(): void public function testGetProviderRequestAuthentication(): void { $this->registry->registerProvider(MockProvider::class); - $mockAuth = new MockRequestAuthentication('another_token'); + $mockAuth = new ApiKeyRequestAuthentication('custom_token'); $this->registry->setProviderRequestAuthentication('mock', $mockAuth); $retrievedAuth = $this->registry->getProviderRequestAuthentication('mock'); @@ -497,7 +499,7 @@ public function testBindModelDependenciesWithRequestAuthentication(): void { // Register provider and set authentication $this->registry->registerProvider(MockProvider::class); - $authentication = new MockRequestAuthentication('test-api-key'); + $authentication = new ApiKeyRequestAuthentication('test-api-key'); $this->registry->setProviderRequestAuthentication('mock', $authentication); // Set HTTP transporter (required by registry) @@ -590,4 +592,49 @@ public function testBindModelDependenciesWithoutHttpTransporter(): void $this->registry->bindModelDependencies($modelInstance); } + + /** + * Tests setProviderRequestAuthentication throws exception when provider expects specific auth type but gets + * another. + * + * @return void + */ + public function testSetProviderRequestAuthenticationThrowsExceptionForInvalidType(): void + { + $this->registry->registerProvider(MockProvider::class); + + $invalidAuth = new class implements RequestAuthenticationInterface { + public function authenticateRequest(Request $request): Request + { + return $request; + } + + public static function getJsonSchema(): array + { + return []; + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/expects authentication of type .*ApiKeyRequestAuthentication, but got/'); + + $this->registry->setProviderRequestAuthentication('mock', $invalidAuth); + } + + /** + * Tests setProviderRequestAuthentication throws exception when provider does not expect authentication. + * + * @return void + */ + public function testSetProviderRequestAuthenticationThrowsExceptionWhenNotExpected(): void + { + $this->registry->registerProvider(MockNoAuthProvider::class); + + $auth = new ApiKeyRequestAuthentication('key'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/does not expect any authentication, but got/'); + + $this->registry->setProviderRequestAuthentication('no-auth', $auth); + } }