From 3e79f62af33202b9e581ea3bc4b8beedb2b3b45b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 2 May 2026 10:56:29 -0400 Subject: [PATCH 1/3] Avoid listing models for explicit text model IDs --- ...AbstractApiBasedModelMetadataDirectory.php | 28 ++++ ...OpenAiCompatibleModelMetadataDirectory.php | 36 +++++ ...ractApiBasedModelMetadataDirectoryTest.php | 68 ++++++++++ .../MockApiBasedModelMetadataDirectory.php | 37 ++++- ...AiCompatibleModelMetadataDirectoryTest.php | 127 ++++++++++++++++++ tests/unit/Providers/ProviderRegistryTest.php | 25 ++++ 6 files changed, 320 insertions(+), 1 deletion(-) diff --git a/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php b/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php index 52c0cd5f..1666183d 100644 --- a/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php +++ b/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php @@ -68,6 +68,18 @@ final public function hasModelMetadata(string $modelId): bool */ final public function getModelMetadata(string $modelId): ModelMetadata { + if ($this->hasCache(self::MODELS_CACHE_KEY)) { + $modelsMetadata = $this->getModelMetadataMap(); + if (isset($modelsMetadata[$modelId])) { + return $modelsMetadata[$modelId]; + } + } + + $explicitModelMetadata = $this->createModelMetadataForExplicitModelId($modelId); + if ($explicitModelMetadata !== null) { + return $explicitModelMetadata; + } + $modelsMetadata = $this->getModelMetadataMap(); if (!isset($modelsMetadata[$modelId])) { throw new InvalidArgumentException( @@ -114,6 +126,22 @@ protected function getBaseCacheKey(): string return 'ai_client_' . AiClient::VERSION . '_' . md5(static::class); } + /** + * Creates metadata for an explicit model ID without listing provider models. + * + * Providers whose APIs accept arbitrary/current model IDs can override this to avoid a live list-models request + * when callers already know the model ID they want to instantiate. + * + * @since n.e.x.t + * + * @param string $modelId The explicit model ID. + * @return ModelMetadata|null The model metadata, or null to fall back to listing provider models. + */ + protected function createModelMetadataForExplicitModelId(string $modelId): ?ModelMetadata + { + return null; + } + /** * Sends the API request to list models from the provider and returns the map of model ID to model metadata. * diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php index 4a1151a6..cf15c066 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php @@ -11,6 +11,7 @@ use WordPress\AiClient\Providers\Http\Exception\ResponseException; use WordPress\AiClient\Providers\Http\Util\ResponseUtil; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; +use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; /** * Base class for a model metadata directory for providers that implement OpenAI's API format. @@ -23,6 +24,41 @@ */ abstract class AbstractOpenAiCompatibleModelMetadataDirectory extends AbstractApiBasedModelMetadataDirectory { + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + protected function createModelMetadataForExplicitModelId(string $modelId): ?ModelMetadata + { + if (!$this->isExplicitTextGenerationModelId($modelId)) { + return null; + } + + return new ModelMetadata($modelId, $modelId, [CapabilityEnum::textGeneration()], []); + } + + /** + * Checks whether a model ID is safe to treat as a text generation model without listing models. + * + * @since n.e.x.t + * + * @param string $modelId The explicit model ID. + * @return bool True if the model ID matches common OpenAI-compatible text generation model families. + */ + protected function isExplicitTextGenerationModelId(string $modelId): bool + { + if (str_starts_with($modelId, 'gpt-image-') || str_starts_with($modelId, 'dall-e-')) { + return false; + } + + if (str_starts_with($modelId, 'gpt-') || str_starts_with($modelId, 'chatgpt-')) { + return true; + } + + return preg_match('/^o\d(?:-|$)/', $modelId) === 1; + } + /** * {@inheritDoc} * diff --git a/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php b/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php index 0313453c..f8a8204c 100644 --- a/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php +++ b/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php @@ -6,7 +6,9 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\AiClient; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; +use WordPress\AiClient\Tests\mocks\MockCache; /** * @covers \WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModelMetadataDirectory @@ -28,6 +30,13 @@ protected function setUp(): void ]; } + protected function tearDown(): void + { + AiClient::setCache(null); + + parent::tearDown(); + } + /** * Tests listModelMetadata() method. * @@ -69,6 +78,65 @@ public function testGetModelMetadata(): void $this->assertSame($this->mockModels['model-1'], $directory->getModelMetadata('model-1')); } + /** + * Tests getModelMetadata() returns explicit metadata without listing models. + * + * @return void + */ + public function testGetModelMetadataReturnsExplicitMetadataWithoutListingModels(): void + { + $explicitModelMetadata = $this->createStub(ModelMetadata::class); + $explicitModelMetadata->method('getId')->willReturn('explicit-model'); + $directory = new MockApiBasedModelMetadataDirectory([], $explicitModelMetadata); + + $this->assertSame($explicitModelMetadata, $directory->getModelMetadata('explicit-model')); + $this->assertSame(0, $directory->getListRequestCount()); + } + + /** + * Tests cached model metadata is preferred over explicit metadata. + * + * @return void + */ + public function testGetModelMetadataPrefersCachedMetadataOverExplicitMetadata(): void + { + $cache = new MockCache(); + $cacheKey = 'ai_client_' . AiClient::VERSION . '_' . md5(MockApiBasedModelMetadataDirectory::class) . '_models'; + $cachedModelMetadata = $this->createStub(ModelMetadata::class); + $cachedModelMetadata->method('getId')->willReturn('explicit-model'); + $cache->seed($cacheKey, ['explicit-model' => $cachedModelMetadata]); + AiClient::setCache($cache); + + $explicitModelMetadata = $this->createStub(ModelMetadata::class); + $explicitModelMetadata->method('getId')->willReturn('explicit-model'); + $directory = new MockApiBasedModelMetadataDirectory([], $explicitModelMetadata); + + $this->assertSame($cachedModelMetadata, $directory->getModelMetadata('explicit-model')); + $this->assertSame(0, $directory->getListRequestCount()); + } + + /** + * Tests cached metadata misses can still fall back to explicit metadata. + * + * @return void + */ + public function testGetModelMetadataUsesExplicitMetadataAfterCachedMetadataMiss(): void + { + $cache = new MockCache(); + $cacheKey = 'ai_client_' . AiClient::VERSION . '_' . md5(MockApiBasedModelMetadataDirectory::class) . '_models'; + $otherModelMetadata = $this->createStub(ModelMetadata::class); + $otherModelMetadata->method('getId')->willReturn('other-model'); + $cache->seed($cacheKey, ['other-model' => $otherModelMetadata]); + AiClient::setCache($cache); + + $explicitModelMetadata = $this->createStub(ModelMetadata::class); + $explicitModelMetadata->method('getId')->willReturn('explicit-model'); + $directory = new MockApiBasedModelMetadataDirectory([], $explicitModelMetadata); + + $this->assertSame($explicitModelMetadata, $directory->getModelMetadata('explicit-model')); + $this->assertSame(0, $directory->getListRequestCount()); + } + /** * Tests getModelMetadata() method with non-existent model. * diff --git a/tests/unit/Providers/ApiBasedImplementation/MockApiBasedModelMetadataDirectory.php b/tests/unit/Providers/ApiBasedImplementation/MockApiBasedModelMetadataDirectory.php index 3635c069..6b5bf518 100644 --- a/tests/unit/Providers/ApiBasedImplementation/MockApiBasedModelMetadataDirectory.php +++ b/tests/unit/Providers/ApiBasedImplementation/MockApiBasedModelMetadataDirectory.php @@ -17,14 +17,25 @@ class MockApiBasedModelMetadataDirectory extends AbstractApiBasedModelMetadataDi */ private array $mockModels; + /** + * @var ModelMetadata|null + */ + private ?ModelMetadata $explicitModelMetadata; + + /** + * @var int + */ + private int $listRequestCount = 0; + /** * Constructor. * * @param array $mockModels */ - public function __construct(array $mockModels = []) + public function __construct(array $mockModels = [], ?ModelMetadata $explicitModelMetadata = null) { $this->mockModels = $mockModels; + $this->explicitModelMetadata = $explicitModelMetadata; } /** @@ -32,6 +43,30 @@ public function __construct(array $mockModels = []) */ protected function sendListModelsRequest(): array { + ++$this->listRequestCount; + return $this->mockModels; } + + /** + * @inheritdoc + */ + protected function createModelMetadataForExplicitModelId(string $modelId): ?ModelMetadata + { + if ($this->explicitModelMetadata !== null && $this->explicitModelMetadata->getId() === $modelId) { + return $this->explicitModelMetadata; + } + + return parent::createModelMetadataForExplicitModelId($modelId); + } + + /** + * Returns the number of list request callbacks. + * + * @return int + */ + public function getListRequestCount(): int + { + return $this->listRequestCount; + } } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php index f8e79541..08c75286 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php @@ -11,6 +11,7 @@ use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; +use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Tests\mocks\MockCache; /** @@ -265,6 +266,132 @@ public function testSendListModelsRequestWorksWithoutCache(): void $this->assertEquals('model-x', $modelsMetadata[0]->getId()); } + /** + * Tests that explicit model metadata does not require listing models from the API. + * + * @return void + */ + public function testGetModelMetadataForExplicitModelIdDoesNotListModels(): void + { + $this->mockHttpTransporter + ->expects($this->never()) + ->method('send'); + + $this->mockRequestAuthentication + ->expects($this->never()) + ->method('authenticateRequest'); + + $directory = new MockOpenAiCompatibleModelMetadataDirectory( + $this->mockHttpTransporter, + $this->mockRequestAuthentication, + null, + [], + true + ); + + $modelMetadata = $directory->getModelMetadata('gpt-5.4'); + + $this->assertEquals('gpt-5.4', $modelMetadata->getId()); + $this->assertEquals('gpt-5.4', $modelMetadata->getName()); + $this->assertEquals([CapabilityEnum::textGeneration()], $modelMetadata->getSupportedCapabilities()); + $this->assertSame([], $modelMetadata->getSupportedOptions()); + } + + /** + * Tests that explicit reasoning model IDs do not require listing models from the API. + * + * @return void + */ + public function testGetModelMetadataForExplicitReasoningModelIdDoesNotListModels(): void + { + $this->mockHttpTransporter + ->expects($this->never()) + ->method('send'); + + $directory = new MockOpenAiCompatibleModelMetadataDirectory( + $this->mockHttpTransporter, + $this->mockRequestAuthentication, + null, + [], + true + ); + + $modelMetadata = $directory->getModelMetadata('o3'); + + $this->assertEquals('o3', $modelMetadata->getId()); + $this->assertEquals([CapabilityEnum::textGeneration()], $modelMetadata->getSupportedCapabilities()); + } + + /** + * Tests that non-text explicit model IDs still use listed model metadata. + * + * @return void + */ + public function testGetModelMetadataForNonTextExplicitModelIdListsModels(): void + { + $response = new Response(200, [], '{"data": [{"id": "gpt-image-1"}]}'); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $directory = new MockOpenAiCompatibleModelMetadataDirectory( + $this->mockHttpTransporter, + $this->mockRequestAuthentication, + null, + [], + true + ); + + $modelMetadata = $directory->getModelMetadata('gpt-image-1'); + + $this->assertEquals('gpt-image-1', $modelMetadata->getId()); + } + + /** + * Tests that cached listed metadata wins over synthetic explicit metadata. + * + * @return void + */ + public function testGetModelMetadataUsesCachedListedMetadataWhenAvailable(): void + { + $cache = new MockCache(); + $cacheKey = 'ai_client_' . AiClient::VERSION . '_' + . md5(MockOpenAiCompatibleModelMetadataDirectory::class) . '_models'; + $cache->seed($cacheKey, [ + 'cached-model' => ModelMetadata::fromArray([ + 'id' => 'cached-model', + 'name' => 'Cached Model', + 'supportedCapabilities' => ['text_generation'], + 'supportedOptions' => [], + ]), + ]); + AiClient::setCache($cache); + + $this->mockHttpTransporter + ->expects($this->never()) + ->method('send'); + + $directory = new MockOpenAiCompatibleModelMetadataDirectory( + $this->mockHttpTransporter, + $this->mockRequestAuthentication, + null, + [], + true + ); + + $modelMetadata = $directory->getModelMetadata('cached-model'); + + $this->assertEquals('cached-model', $modelMetadata->getId()); + $this->assertEquals('Cached Model', $modelMetadata->getName()); + } + /** * Tests that cache keys are unique per child class. * diff --git a/tests/unit/Providers/ProviderRegistryTest.php b/tests/unit/Providers/ProviderRegistryTest.php index ff8197e6..214bb8ad 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\Http\Contracts\HttpTransporterInterface; use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; use WordPress\AiClient\Providers\Http\DTO\Request; @@ -20,6 +21,8 @@ use WordPress\AiClient\Tests\mocks\MockNoAuthProvider; use WordPress\AiClient\Tests\mocks\MockProvider; use WordPress\AiClient\Tests\mocks\MockProviderAvailability; +use WordPress\OpenAiAiProvider\Models\OpenAiTextGenerationModel; +use WordPress\OpenAiAiProvider\Provider\OpenAiProvider; /** * @covers \WordPress\AiClient\Providers\ProviderRegistry @@ -228,6 +231,28 @@ public function testGetProviderModelThrowsException(): void $this->registry->getProviderModel('mock', 'test-model', $modelConfig); } + /** + * Tests that explicit OpenAI model IDs instantiate without listing provider models. + * + * @return void + */ + public function testGetProviderModelWithExplicitOpenAiModelIdDoesNotListModels(): void + { + $httpTransporter = $this->createMock(HttpTransporterInterface::class); + $httpTransporter + ->expects($this->never()) + ->method('send'); + + $this->registry->setHttpTransporter($httpTransporter); + $this->registry->registerProvider(OpenAiProvider::class); + $this->registry->setProviderRequestAuthentication('openai', new ApiKeyRequestAuthentication('test-api-key')); + + $model = $this->registry->getProviderModel('openai', 'gpt-5.4', new ModelConfig([])); + + $this->assertInstanceOf(OpenAiTextGenerationModel::class, $model); + $this->assertEquals('gpt-5.4', $model->metadata()->getId()); + } + /** * Tests multiple provider registration. * From 123286e14d254dfe6cc804624daa0e3ca86e50fd Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 7 May 2026 10:10:05 -0400 Subject: [PATCH 2/3] Move OpenAI-specific explicit-model logic out of OpenAI-compatible base The OpenAI-compatible abstraction is about HTTP/JSON shape, not OpenAI's model namespace. Drop the gpt-/o3/dall-e prefix gating and the synthetic text-generation metadata override; the per-provider override belongs in ai-provider-for-openai (and similar repos for other compatible providers). The generic createModelMetadataForExplicitModelId() hook on AbstractApiBasedModelMetadataDirectory is unchanged so providers can still opt in. --- ...OpenAiCompatibleModelMetadataDirectory.php | 36 ----- ...AiCompatibleModelMetadataDirectoryTest.php | 127 ------------------ tests/unit/Providers/ProviderRegistryTest.php | 25 ---- 3 files changed, 188 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php index cf15c066..4a1151a6 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectory.php @@ -11,7 +11,6 @@ use WordPress\AiClient\Providers\Http\Exception\ResponseException; use WordPress\AiClient\Providers\Http\Util\ResponseUtil; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; -use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; /** * Base class for a model metadata directory for providers that implement OpenAI's API format. @@ -24,41 +23,6 @@ */ abstract class AbstractOpenAiCompatibleModelMetadataDirectory extends AbstractApiBasedModelMetadataDirectory { - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - protected function createModelMetadataForExplicitModelId(string $modelId): ?ModelMetadata - { - if (!$this->isExplicitTextGenerationModelId($modelId)) { - return null; - } - - return new ModelMetadata($modelId, $modelId, [CapabilityEnum::textGeneration()], []); - } - - /** - * Checks whether a model ID is safe to treat as a text generation model without listing models. - * - * @since n.e.x.t - * - * @param string $modelId The explicit model ID. - * @return bool True if the model ID matches common OpenAI-compatible text generation model families. - */ - protected function isExplicitTextGenerationModelId(string $modelId): bool - { - if (str_starts_with($modelId, 'gpt-image-') || str_starts_with($modelId, 'dall-e-')) { - return false; - } - - if (str_starts_with($modelId, 'gpt-') || str_starts_with($modelId, 'chatgpt-')) { - return true; - } - - return preg_match('/^o\d(?:-|$)/', $modelId) === 1; - } - /** * {@inheritDoc} * diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php index 08c75286..f8e79541 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleModelMetadataDirectoryTest.php @@ -11,7 +11,6 @@ use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; -use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Tests\mocks\MockCache; /** @@ -266,132 +265,6 @@ public function testSendListModelsRequestWorksWithoutCache(): void $this->assertEquals('model-x', $modelsMetadata[0]->getId()); } - /** - * Tests that explicit model metadata does not require listing models from the API. - * - * @return void - */ - public function testGetModelMetadataForExplicitModelIdDoesNotListModels(): void - { - $this->mockHttpTransporter - ->expects($this->never()) - ->method('send'); - - $this->mockRequestAuthentication - ->expects($this->never()) - ->method('authenticateRequest'); - - $directory = new MockOpenAiCompatibleModelMetadataDirectory( - $this->mockHttpTransporter, - $this->mockRequestAuthentication, - null, - [], - true - ); - - $modelMetadata = $directory->getModelMetadata('gpt-5.4'); - - $this->assertEquals('gpt-5.4', $modelMetadata->getId()); - $this->assertEquals('gpt-5.4', $modelMetadata->getName()); - $this->assertEquals([CapabilityEnum::textGeneration()], $modelMetadata->getSupportedCapabilities()); - $this->assertSame([], $modelMetadata->getSupportedOptions()); - } - - /** - * Tests that explicit reasoning model IDs do not require listing models from the API. - * - * @return void - */ - public function testGetModelMetadataForExplicitReasoningModelIdDoesNotListModels(): void - { - $this->mockHttpTransporter - ->expects($this->never()) - ->method('send'); - - $directory = new MockOpenAiCompatibleModelMetadataDirectory( - $this->mockHttpTransporter, - $this->mockRequestAuthentication, - null, - [], - true - ); - - $modelMetadata = $directory->getModelMetadata('o3'); - - $this->assertEquals('o3', $modelMetadata->getId()); - $this->assertEquals([CapabilityEnum::textGeneration()], $modelMetadata->getSupportedCapabilities()); - } - - /** - * Tests that non-text explicit model IDs still use listed model metadata. - * - * @return void - */ - public function testGetModelMetadataForNonTextExplicitModelIdListsModels(): void - { - $response = new Response(200, [], '{"data": [{"id": "gpt-image-1"}]}'); - - $this->mockRequestAuthentication - ->expects($this->once()) - ->method('authenticateRequest') - ->willReturnArgument(0); - - $this->mockHttpTransporter - ->expects($this->once()) - ->method('send') - ->willReturn($response); - - $directory = new MockOpenAiCompatibleModelMetadataDirectory( - $this->mockHttpTransporter, - $this->mockRequestAuthentication, - null, - [], - true - ); - - $modelMetadata = $directory->getModelMetadata('gpt-image-1'); - - $this->assertEquals('gpt-image-1', $modelMetadata->getId()); - } - - /** - * Tests that cached listed metadata wins over synthetic explicit metadata. - * - * @return void - */ - public function testGetModelMetadataUsesCachedListedMetadataWhenAvailable(): void - { - $cache = new MockCache(); - $cacheKey = 'ai_client_' . AiClient::VERSION . '_' - . md5(MockOpenAiCompatibleModelMetadataDirectory::class) . '_models'; - $cache->seed($cacheKey, [ - 'cached-model' => ModelMetadata::fromArray([ - 'id' => 'cached-model', - 'name' => 'Cached Model', - 'supportedCapabilities' => ['text_generation'], - 'supportedOptions' => [], - ]), - ]); - AiClient::setCache($cache); - - $this->mockHttpTransporter - ->expects($this->never()) - ->method('send'); - - $directory = new MockOpenAiCompatibleModelMetadataDirectory( - $this->mockHttpTransporter, - $this->mockRequestAuthentication, - null, - [], - true - ); - - $modelMetadata = $directory->getModelMetadata('cached-model'); - - $this->assertEquals('cached-model', $modelMetadata->getId()); - $this->assertEquals('Cached Model', $modelMetadata->getName()); - } - /** * Tests that cache keys are unique per child class. * diff --git a/tests/unit/Providers/ProviderRegistryTest.php b/tests/unit/Providers/ProviderRegistryTest.php index 214bb8ad..ff8197e6 100644 --- a/tests/unit/Providers/ProviderRegistryTest.php +++ b/tests/unit/Providers/ProviderRegistryTest.php @@ -6,7 +6,6 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; -use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; use WordPress\AiClient\Providers\Http\DTO\Request; @@ -21,8 +20,6 @@ use WordPress\AiClient\Tests\mocks\MockNoAuthProvider; use WordPress\AiClient\Tests\mocks\MockProvider; use WordPress\AiClient\Tests\mocks\MockProviderAvailability; -use WordPress\OpenAiAiProvider\Models\OpenAiTextGenerationModel; -use WordPress\OpenAiAiProvider\Provider\OpenAiProvider; /** * @covers \WordPress\AiClient\Providers\ProviderRegistry @@ -231,28 +228,6 @@ public function testGetProviderModelThrowsException(): void $this->registry->getProviderModel('mock', 'test-model', $modelConfig); } - /** - * Tests that explicit OpenAI model IDs instantiate without listing provider models. - * - * @return void - */ - public function testGetProviderModelWithExplicitOpenAiModelIdDoesNotListModels(): void - { - $httpTransporter = $this->createMock(HttpTransporterInterface::class); - $httpTransporter - ->expects($this->never()) - ->method('send'); - - $this->registry->setHttpTransporter($httpTransporter); - $this->registry->registerProvider(OpenAiProvider::class); - $this->registry->setProviderRequestAuthentication('openai', new ApiKeyRequestAuthentication('test-api-key')); - - $model = $this->registry->getProviderModel('openai', 'gpt-5.4', new ModelConfig([])); - - $this->assertInstanceOf(OpenAiTextGenerationModel::class, $model); - $this->assertEquals('gpt-5.4', $model->metadata()->getId()); - } - /** * Tests multiple provider registration. * From b5b0e68e9e51aacea1d250dd0ef57827d1f67e6b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 10 Jun 2026 23:32:06 -0400 Subject: [PATCH 3/3] Keep explicit model metadata consistent --- ...AbstractApiBasedModelMetadataDirectory.php | 61 +++++++++++-- ...ractApiBasedModelMetadataDirectoryTest.php | 91 ++++++++++++++++++- .../MockApiBasedModelMetadataDirectory.php | 17 ++++ 3 files changed, 157 insertions(+), 12 deletions(-) diff --git a/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php b/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php index 1666183d..d202b154 100644 --- a/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php +++ b/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php @@ -39,6 +39,15 @@ abstract class AbstractApiBasedModelMetadataDirectory implements */ private const MODELS_CACHE_KEY = 'models'; + /** + * Request-local cache for explicit model metadata lookups. + * + * @since n.e.x.t + * + * @var array + */ + private array $explicitModelMetadataCache = []; + /** * {@inheritDoc} * @@ -47,7 +56,7 @@ abstract class AbstractApiBasedModelMetadataDirectory implements final public function listModelMetadata(): array { $modelsMetadata = $this->getModelMetadataMap(); - return array_values($modelsMetadata); + return array_values($this->applyExplicitModelMetadataOverrides($modelsMetadata)); } /** @@ -57,6 +66,10 @@ final public function listModelMetadata(): array */ final public function hasModelMetadata(string $modelId): bool { + if ($this->getExplicitModelMetadata($modelId) !== null) { + return true; + } + $modelsMetadata = $this->getModelMetadataMap(); return isset($modelsMetadata[$modelId]); } @@ -68,14 +81,7 @@ final public function hasModelMetadata(string $modelId): bool */ final public function getModelMetadata(string $modelId): ModelMetadata { - if ($this->hasCache(self::MODELS_CACHE_KEY)) { - $modelsMetadata = $this->getModelMetadataMap(); - if (isset($modelsMetadata[$modelId])) { - return $modelsMetadata[$modelId]; - } - } - - $explicitModelMetadata = $this->createModelMetadataForExplicitModelId($modelId); + $explicitModelMetadata = $this->getExplicitModelMetadata($modelId); if ($explicitModelMetadata !== null) { return $explicitModelMetadata; } @@ -89,6 +95,43 @@ final public function getModelMetadata(string $modelId): ModelMetadata return $modelsMetadata[$modelId]; } + /** + * Applies explicit metadata overrides to listed model metadata. + * + * @since n.e.x.t + * + * @param array $modelsMetadata Map of model ID to model metadata. + * @return array Map of model ID to model metadata with explicit overrides applied. + */ + private function applyExplicitModelMetadataOverrides(array $modelsMetadata): array + { + foreach (array_keys($modelsMetadata) as $modelId) { + $explicitModelMetadata = $this->getExplicitModelMetadata($modelId); + if ($explicitModelMetadata !== null) { + $modelsMetadata[$modelId] = $explicitModelMetadata; + } + } + + return $modelsMetadata; + } + + /** + * Gets explicit model metadata using request-local memoization. + * + * @since n.e.x.t + * + * @param string $modelId The explicit model ID. + * @return ModelMetadata|null The model metadata, or null to fall back to listing provider models. + */ + private function getExplicitModelMetadata(string $modelId): ?ModelMetadata + { + if (!array_key_exists($modelId, $this->explicitModelMetadataCache)) { + $this->explicitModelMetadataCache[$modelId] = $this->createModelMetadataForExplicitModelId($modelId); + } + + return $this->explicitModelMetadataCache[$modelId]; + } + /** * Returns the map of model ID to model metadata for all models from the provider. * diff --git a/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php b/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php index f8a8204c..60243350 100644 --- a/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php +++ b/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php @@ -94,11 +94,11 @@ public function testGetModelMetadataReturnsExplicitMetadataWithoutListingModels( } /** - * Tests cached model metadata is preferred over explicit metadata. + * Tests explicit model metadata is preferred over cached metadata. * * @return void */ - public function testGetModelMetadataPrefersCachedMetadataOverExplicitMetadata(): void + public function testGetModelMetadataPrefersExplicitMetadataOverCachedMetadata(): void { $cache = new MockCache(); $cacheKey = 'ai_client_' . AiClient::VERSION . '_' . md5(MockApiBasedModelMetadataDirectory::class) . '_models'; @@ -111,7 +111,7 @@ public function testGetModelMetadataPrefersCachedMetadataOverExplicitMetadata(): $explicitModelMetadata->method('getId')->willReturn('explicit-model'); $directory = new MockApiBasedModelMetadataDirectory([], $explicitModelMetadata); - $this->assertSame($cachedModelMetadata, $directory->getModelMetadata('explicit-model')); + $this->assertSame($explicitModelMetadata, $directory->getModelMetadata('explicit-model')); $this->assertSame(0, $directory->getListRequestCount()); } @@ -137,6 +137,91 @@ public function testGetModelMetadataUsesExplicitMetadataAfterCachedMetadataMiss( $this->assertSame(0, $directory->getListRequestCount()); } + /** + * Tests hasModelMetadata() returns true for explicit metadata without listing models. + * + * @return void + */ + public function testHasModelMetadataReturnsTrueForExplicitMetadataWithoutListingModels(): void + { + $explicitModelMetadata = $this->createStub(ModelMetadata::class); + $explicitModelMetadata->method('getId')->willReturn('explicit-model'); + $directory = new MockApiBasedModelMetadataDirectory([], $explicitModelMetadata); + + $this->assertTrue($directory->hasModelMetadata('explicit-model')); + $this->assertSame(0, $directory->getListRequestCount()); + } + + /** + * Tests listModelMetadata() applies explicit metadata overrides to listed models. + * + * @return void + */ + public function testListModelMetadataAppliesExplicitMetadataOverrides(): void + { + $listedModelMetadata = $this->createStub(ModelMetadata::class); + $listedModelMetadata->method('getId')->willReturn('explicit-model'); + + $explicitModelMetadata = $this->createStub(ModelMetadata::class); + $explicitModelMetadata->method('getId')->willReturn('explicit-model'); + + $directory = new MockApiBasedModelMetadataDirectory( + ['explicit-model' => $listedModelMetadata], + $explicitModelMetadata + ); + + $models = $directory->listModelMetadata(); + + $this->assertCount(1, $models); + $this->assertSame($explicitModelMetadata, $models[0]); + $this->assertSame($explicitModelMetadata, $directory->getModelMetadata('explicit-model')); + $this->assertSame(1, $directory->getExplicitModelMetadataLookupCount()); + } + + /** + * Tests listModelMetadata() applies explicit metadata overrides to cached models. + * + * @return void + */ + public function testListModelMetadataAppliesExplicitMetadataOverridesToCachedModels(): void + { + $cache = new MockCache(); + $cacheKey = 'ai_client_' . AiClient::VERSION . '_' . md5(MockApiBasedModelMetadataDirectory::class) . '_models'; + + $cachedModelMetadata = $this->createStub(ModelMetadata::class); + $cachedModelMetadata->method('getId')->willReturn('explicit-model'); + $cache->seed($cacheKey, ['explicit-model' => $cachedModelMetadata]); + AiClient::setCache($cache); + + $explicitModelMetadata = $this->createStub(ModelMetadata::class); + $explicitModelMetadata->method('getId')->willReturn('explicit-model'); + $directory = new MockApiBasedModelMetadataDirectory([], $explicitModelMetadata); + + $models = $directory->listModelMetadata(); + + $this->assertCount(1, $models); + $this->assertSame($explicitModelMetadata, $models[0]); + $this->assertSame(0, $directory->getListRequestCount()); + } + + /** + * Tests explicit model metadata is memoized for a directory instance. + * + * @return void + */ + public function testExplicitModelMetadataIsMemoized(): void + { + $explicitModelMetadata = $this->createStub(ModelMetadata::class); + $explicitModelMetadata->method('getId')->willReturn('explicit-model'); + $directory = new MockApiBasedModelMetadataDirectory([], $explicitModelMetadata); + + $this->assertSame($explicitModelMetadata, $directory->getModelMetadata('explicit-model')); + $this->assertSame($explicitModelMetadata, $directory->getModelMetadata('explicit-model')); + $this->assertTrue($directory->hasModelMetadata('explicit-model')); + $this->assertSame(1, $directory->getExplicitModelMetadataLookupCount()); + $this->assertSame(0, $directory->getListRequestCount()); + } + /** * Tests getModelMetadata() method with non-existent model. * diff --git a/tests/unit/Providers/ApiBasedImplementation/MockApiBasedModelMetadataDirectory.php b/tests/unit/Providers/ApiBasedImplementation/MockApiBasedModelMetadataDirectory.php index 6b5bf518..28ea0683 100644 --- a/tests/unit/Providers/ApiBasedImplementation/MockApiBasedModelMetadataDirectory.php +++ b/tests/unit/Providers/ApiBasedImplementation/MockApiBasedModelMetadataDirectory.php @@ -27,6 +27,11 @@ class MockApiBasedModelMetadataDirectory extends AbstractApiBasedModelMetadataDi */ private int $listRequestCount = 0; + /** + * @var int + */ + private int $explicitModelMetadataLookupCount = 0; + /** * Constructor. * @@ -53,6 +58,8 @@ protected function sendListModelsRequest(): array */ protected function createModelMetadataForExplicitModelId(string $modelId): ?ModelMetadata { + ++$this->explicitModelMetadataLookupCount; + if ($this->explicitModelMetadata !== null && $this->explicitModelMetadata->getId() === $modelId) { return $this->explicitModelMetadata; } @@ -69,4 +76,14 @@ public function getListRequestCount(): int { return $this->listRequestCount; } + + /** + * Returns the number of explicit model metadata lookups. + * + * @return int + */ + public function getExplicitModelMetadataLookupCount(): int + { + return $this->explicitModelMetadataLookupCount; + } }