From 8cdf39fc4ab83764f488b567f45cd4ae029f588f Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Thu, 11 Dec 2025 21:47:59 -0800 Subject: [PATCH 1/2] Add provider awareness of their authentication method, decoupling from the assumption of all using API keys. --- .../Anthropic/AnthropicProvider.php | 4 +- .../Google/GoogleProvider.php | 4 +- .../OpenAi/OpenAiProvider.php | 4 +- src/Providers/DTO/ProviderMetadata.php | 48 ++++++++++++++-- .../Enums/RequestAuthenticationMethod.php | 45 +++++++++++++++ src/Providers/ProviderRegistry.php | 50 ++++++++++++++--- tests/mocks/MockNoAuthProvider.php | 52 ++++++++++++++++++ tests/mocks/MockRequestAuthentication.php | 50 ----------------- .../Providers/DTO/ProviderMetadataTest.php | 4 +- tests/unit/Providers/ProviderRegistryTest.php | 55 +++++++++++++++++-- 10 files changed, 245 insertions(+), 71 deletions(-) create mode 100644 src/Providers/Http/Enums/RequestAuthenticationMethod.php create mode 100644 tests/mocks/MockNoAuthProvider.php delete mode 100644 tests/mocks/MockRequestAuthentication.php 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..c913759b 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,24 @@ 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; + + if ($authenticationMethod === null && $type->isCloud()) { + $authenticationMethod = RequestAuthenticationMethod::apiKey(); + } + $this->authenticationMethod = $authenticationMethod; } /** @@ -117,6 +136,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 +175,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 +199,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 +216,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..d6382073 --- /dev/null +++ b/src/Providers/Http/Enums/RequestAuthenticationMethod.php @@ -0,0 +1,45 @@ +|null The implementation + * class, or null if + * none. + * @phpstan-ignore missingType.generics + */ + public function getImplementationClass(): ?string + { + if ($this->isApiKey()) { + return ApiKeyRequestAuthentication::class; + } + + return null; + } +} diff --git a/src/Providers/ProviderRegistry.php b/src/Providers/ProviderRegistry.php index f937a44b..360b4b0d 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,37 @@ 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(); + $expectedClass = $authenticationMethod ? $authenticationMethod->getImplementationClass() : null; + + if ($expectedClass === null) { + throw new InvalidArgumentException( + sprintf( + 'Provider %s does not expect any authentication, but got %s.', + $className, + get_class($requestAuthentication) + ) + ); + } + + 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 +503,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 +565,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..f3697515 100644 --- a/tests/unit/Providers/DTO/ProviderMetadataTest.php +++ b/tests/unit/Providers/DTO/ProviderMetadataTest.php @@ -10,6 +10,7 @@ use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; +use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; /** * @covers \WordPress\AiClient\Providers\DTO\ProviderMetadata @@ -134,7 +135,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->assertEquals(RequestAuthenticationMethod::API_KEY, $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); + } } From 8944cd8adce15ca3e86f18c0b8bc76f2485df7b1 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Fri, 12 Dec 2025 16:26:30 -0800 Subject: [PATCH 2/2] Fix unreliable API key assumption for cloud providers, and make getImplementationClass() return type non-nullable. --- src/Providers/DTO/ProviderMetadata.php | 4 ---- .../Http/Enums/RequestAuthenticationMethod.php | 15 ++++++--------- src/Providers/ProviderRegistry.php | 5 ++--- tests/mocks/MockProvider.php | 5 ++++- tests/unit/Providers/DTO/ProviderMetadataTest.php | 3 +-- 5 files changed, 13 insertions(+), 19 deletions(-) diff --git a/src/Providers/DTO/ProviderMetadata.php b/src/Providers/DTO/ProviderMetadata.php index c913759b..ec5e6fc5 100644 --- a/src/Providers/DTO/ProviderMetadata.php +++ b/src/Providers/DTO/ProviderMetadata.php @@ -81,10 +81,6 @@ public function __construct( $this->name = $name; $this->type = $type; $this->credentialsUrl = $credentialsUrl; - - if ($authenticationMethod === null && $type->isCloud()) { - $authenticationMethod = RequestAuthenticationMethod::apiKey(); - } $this->authenticationMethod = $authenticationMethod; } diff --git a/src/Providers/Http/Enums/RequestAuthenticationMethod.php b/src/Providers/Http/Enums/RequestAuthenticationMethod.php index d6382073..27de753f 100644 --- a/src/Providers/Http/Enums/RequestAuthenticationMethod.php +++ b/src/Providers/Http/Enums/RequestAuthenticationMethod.php @@ -29,17 +29,14 @@ class RequestAuthenticationMethod extends AbstractEnum * * @since n.e.x.t * - * @return class-string|null The implementation - * class, or null if - * none. + * @return class-string The implementation class. + * * @phpstan-ignore missingType.generics */ - public function getImplementationClass(): ?string + public function getImplementationClass(): string { - if ($this->isApiKey()) { - return ApiKeyRequestAuthentication::class; - } - - return null; + // 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 360b4b0d..e64a5e8f 100644 --- a/src/Providers/ProviderRegistry.php +++ b/src/Providers/ProviderRegistry.php @@ -450,9 +450,7 @@ private function setRequestAuthenticationForProvider( RequestAuthenticationInterface $requestAuthentication ): void { $authenticationMethod = $className::metadata()->getAuthenticationMethod(); - $expectedClass = $authenticationMethod ? $authenticationMethod->getImplementationClass() : null; - - if ($expectedClass === null) { + if ($authenticationMethod === null) { throw new InvalidArgumentException( sprintf( 'Provider %s does not expect any authentication, but got %s.', @@ -462,6 +460,7 @@ private function setRequestAuthenticationForProvider( ); } + $expectedClass = $authenticationMethod->getImplementationClass(); if (!$requestAuthentication instanceof $expectedClass) { throw new InvalidArgumentException( sprintf( diff --git a/tests/mocks/MockProvider.php b/tests/mocks/MockProvider.php index 8414046d..d1f387ae 100644 --- a/tests/mocks/MockProvider.php +++ b/tests/mocks/MockProvider.php @@ -9,6 +9,7 @@ use WordPress\AiClient\Providers\Contracts\ProviderInterface; 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\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -37,7 +38,9 @@ public static function metadata(): ProviderMetadata return new ProviderMetadata( 'mock', 'Mock Provider', - ProviderTypeEnum::cloud() + ProviderTypeEnum::cloud(), + null, + RequestAuthenticationMethod::apiKey() ); } diff --git a/tests/unit/Providers/DTO/ProviderMetadataTest.php b/tests/unit/Providers/DTO/ProviderMetadataTest.php index f3697515..7eee7956 100644 --- a/tests/unit/Providers/DTO/ProviderMetadataTest.php +++ b/tests/unit/Providers/DTO/ProviderMetadataTest.php @@ -10,7 +10,6 @@ use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; -use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod; /** * @covers \WordPress\AiClient\Providers\DTO\ProviderMetadata @@ -135,7 +134,7 @@ 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->assertEquals(RequestAuthenticationMethod::API_KEY, $array[ProviderMetadata::KEY_AUTHENTICATION_METHOD]); + $this->assertNull($array[ProviderMetadata::KEY_AUTHENTICATION_METHOD]); $this->assertCount(5, $array); }