diff --git a/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php b/src/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectory.php index 52c0cd5f..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,6 +81,11 @@ final public function hasModelMetadata(string $modelId): bool */ final public function getModelMetadata(string $modelId): ModelMetadata { + $explicitModelMetadata = $this->getExplicitModelMetadata($modelId); + if ($explicitModelMetadata !== null) { + return $explicitModelMetadata; + } + $modelsMetadata = $this->getModelMetadataMap(); if (!isset($modelsMetadata[$modelId])) { throw new InvalidArgumentException( @@ -77,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. * @@ -114,6 +169,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/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php b/tests/unit/Providers/ApiBasedImplementation/AbstractApiBasedModelMetadataDirectoryTest.php index 0313453c..60243350 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,150 @@ 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 explicit model metadata is preferred over cached metadata. + * + * @return void + */ + public function testGetModelMetadataPrefersExplicitMetadataOverCachedMetadata(): 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($explicitModelMetadata, $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 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 3635c069..28ea0683 100644 --- a/tests/unit/Providers/ApiBasedImplementation/MockApiBasedModelMetadataDirectory.php +++ b/tests/unit/Providers/ApiBasedImplementation/MockApiBasedModelMetadataDirectory.php @@ -17,14 +17,30 @@ class MockApiBasedModelMetadataDirectory extends AbstractApiBasedModelMetadataDi */ private array $mockModels; + /** + * @var ModelMetadata|null + */ + private ?ModelMetadata $explicitModelMetadata; + + /** + * @var int + */ + private int $listRequestCount = 0; + + /** + * @var int + */ + private int $explicitModelMetadataLookupCount = 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 +48,42 @@ public function __construct(array $mockModels = []) */ protected function sendListModelsRequest(): array { + ++$this->listRequestCount; + return $this->mockModels; } + + /** + * @inheritdoc + */ + protected function createModelMetadataForExplicitModelId(string $modelId): ?ModelMetadata + { + ++$this->explicitModelMetadataLookupCount; + + 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; + } + + /** + * Returns the number of explicit model metadata lookups. + * + * @return int + */ + public function getExplicitModelMetadataLookupCount(): int + { + return $this->explicitModelMetadataLookupCount; + } }