diff --git a/src/platform/tests/Bridge/OpenRouter/ModelApiCatalogTest.php b/src/platform/tests/Bridge/OpenRouter/ModelApiCatalogTest.php new file mode 100644 index 000000000..f37ab15a6 --- /dev/null +++ b/src/platform/tests/Bridge/OpenRouter/ModelApiCatalogTest.php @@ -0,0 +1,273 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\OpenRouter; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Generic\CompletionsModel; +use Symfony\AI\Platform\Bridge\Generic\EmbeddingsModel; +use Symfony\AI\Platform\Bridge\OpenRouter\ModelApiCatalog; +use Symfony\AI\Platform\Capability; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +/** + * @author Oskar Stark + */ +final class ModelApiCatalogTest extends TestCase +{ + public function testGetModelLoadsFromApi() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'data' => [ + [ + 'id' => 'anthropic/claude-3-opus', + 'architecture' => [ + 'input_modalities' => ['text', 'image'], + 'output_modalities' => ['text'], + ], + ], + ], + ]), + new JsonMockResponse([ + 'data' => [], + ]), + ]); + + $catalog = new ModelApiCatalog($httpClient); + $model = $catalog->getModel('anthropic/claude-3-opus'); + + $this->assertSame('anthropic/claude-3-opus', $model->getName()); + $this->assertContains(Capability::INPUT_TEXT, $model->getCapabilities()); + $this->assertContains(Capability::INPUT_IMAGE, $model->getCapabilities()); + $this->assertContains(Capability::OUTPUT_TEXT, $model->getCapabilities()); + $this->assertSame(2, $httpClient->getRequestsCount()); + } + + public function testGetModelsLoadsFromApi() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'data' => [ + [ + 'id' => 'openai/gpt-4', + 'architecture' => [ + 'input_modalities' => ['text'], + 'output_modalities' => ['text'], + ], + ], + [ + 'id' => 'google/gemini-pro-vision', + 'architecture' => [ + 'input_modalities' => ['text', 'image'], + 'output_modalities' => ['text'], + ], + ], + ], + ]), + new JsonMockResponse([ + 'data' => [ + [ + 'id' => 'openai/text-embedding-ada-002', + ], + ], + ]), + ]); + + $catalog = new ModelApiCatalog($httpClient); + $models = $catalog->getModels(); + + // Should include base models (openrouter/auto, @preset) + API models + embeddings + $this->assertArrayHasKey('openrouter/auto', $models); + $this->assertArrayHasKey('@preset', $models); + $this->assertArrayHasKey('openai/gpt-4', $models); + $this->assertArrayHasKey('google/gemini-pro-vision', $models); + $this->assertArrayHasKey('openai/text-embedding-ada-002', $models); + + $this->assertSame(CompletionsModel::class, $models['openai/gpt-4']['class']); + $this->assertSame(CompletionsModel::class, $models['google/gemini-pro-vision']['class']); + $this->assertSame(EmbeddingsModel::class, $models['openai/text-embedding-ada-002']['class']); + + $this->assertSame(2, $httpClient->getRequestsCount()); + } + + public function testModelsAreOnlyLoadedOnce() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'data' => [ + [ + 'id' => 'test/model', + 'architecture' => [ + 'input_modalities' => ['text'], + 'output_modalities' => ['text'], + ], + ], + ], + ]), + new JsonMockResponse([ + 'data' => [], + ]), + ]); + + $catalog = new ModelApiCatalog($httpClient); + + // Call getModels twice + $catalog->getModels(); + $catalog->getModels(); + + // Should only make 2 API calls total (models + embeddings), not 4 + $this->assertSame(2, $httpClient->getRequestsCount()); + } + + public function testGetModelWithAudioInputModality() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'data' => [ + [ + 'id' => 'openai/whisper', + 'architecture' => [ + 'input_modalities' => ['audio'], + 'output_modalities' => ['text'], + ], + ], + ], + ]), + new JsonMockResponse([ + 'data' => [], + ]), + ]); + + $catalog = new ModelApiCatalog($httpClient); + $model = $catalog->getModel('openai/whisper'); + + $this->assertContains(Capability::INPUT_AUDIO, $model->getCapabilities()); + $this->assertContains(Capability::OUTPUT_TEXT, $model->getCapabilities()); + } + + public function testGetModelWithFileInputModality() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'data' => [ + [ + 'id' => 'anthropic/claude-pdf', + 'architecture' => [ + 'input_modalities' => ['text', 'file'], + 'output_modalities' => ['text'], + ], + ], + ], + ]), + new JsonMockResponse([ + 'data' => [], + ]), + ]); + + $catalog = new ModelApiCatalog($httpClient); + $model = $catalog->getModel('anthropic/claude-pdf'); + + $this->assertContains(Capability::INPUT_TEXT, $model->getCapabilities()); + $this->assertContains(Capability::INPUT_PDF, $model->getCapabilities()); + $this->assertContains(Capability::OUTPUT_TEXT, $model->getCapabilities()); + } + + public function testGetModelWithVideoInputModality() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'data' => [ + [ + 'id' => 'google/gemini-video', + 'architecture' => [ + 'input_modalities' => ['text', 'video'], + 'output_modalities' => ['text'], + ], + ], + ], + ]), + new JsonMockResponse([ + 'data' => [], + ]), + ]); + + $catalog = new ModelApiCatalog($httpClient); + $model = $catalog->getModel('google/gemini-video'); + + $this->assertContains(Capability::INPUT_TEXT, $model->getCapabilities()); + $this->assertContains(Capability::INPUT_MULTIMODAL, $model->getCapabilities()); + $this->assertContains(Capability::OUTPUT_TEXT, $model->getCapabilities()); + } + + public function testGetModelWithImageOutputModality() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'data' => [ + [ + 'id' => 'openai/dall-e', + 'architecture' => [ + 'input_modalities' => ['text'], + 'output_modalities' => ['image'], + ], + ], + ], + ]), + new JsonMockResponse([ + 'data' => [], + ]), + ]); + + $catalog = new ModelApiCatalog($httpClient); + $model = $catalog->getModel('openai/dall-e'); + + $this->assertContains(Capability::INPUT_TEXT, $model->getCapabilities()); + $this->assertContains(Capability::OUTPUT_IMAGE, $model->getCapabilities()); + } + + public function testPresetModelStillWorksWithApiCatalog() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'data' => [], + ]), + new JsonMockResponse([ + 'data' => [], + ]), + ]); + + $catalog = new ModelApiCatalog($httpClient); + $model = $catalog->getModel('@preset/my-preset'); + + $this->assertSame('@preset/my-preset', $model->getName()); + $this->assertSame(Capability::cases(), $model->getCapabilities()); + } + + public function testAutoRouterModelStillWorksWithApiCatalog() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'data' => [], + ]), + new JsonMockResponse([ + 'data' => [], + ]), + ]); + + $catalog = new ModelApiCatalog($httpClient); + $model = $catalog->getModel('openrouter/auto'); + + $this->assertSame('openrouter/auto', $model->getName()); + $this->assertSame(Capability::cases(), $model->getCapabilities()); + } +} diff --git a/src/platform/tests/Bridge/OpenRouter/ModelCatalogTest.php b/src/platform/tests/Bridge/OpenRouter/ModelCatalogTest.php new file mode 100644 index 000000000..8ff5c4463 --- /dev/null +++ b/src/platform/tests/Bridge/OpenRouter/ModelCatalogTest.php @@ -0,0 +1,182 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\OpenRouter; + +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\AI\Platform\Bridge\Generic\CompletionsModel; +use Symfony\AI\Platform\Bridge\Generic\EmbeddingsModel; +use Symfony\AI\Platform\Bridge\OpenRouter\ModelCatalog; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; +use Symfony\AI\Platform\Test\ModelCatalogTestCase; + +/** + * @author Oskar Stark + */ +final class ModelCatalogTest extends ModelCatalogTestCase +{ + public static function modelsProvider(): iterable + { + yield 'openrouter/auto' => [ + 'openrouter/auto', + CompletionsModel::class, + [ + Capability::INPUT_TEXT, + Capability::OUTPUT_TEXT, + ], + ]; + + yield 'anthropic/claude-sonnet-4.5' => [ + 'anthropic/claude-sonnet-4.5', + CompletionsModel::class, + [ + Capability::INPUT_TEXT, + Capability::INPUT_IMAGE, + Capability::INPUT_PDF, + Capability::OUTPUT_TEXT, + ], + ]; + + yield 'openai/gpt-5' => [ + 'openai/gpt-5', + CompletionsModel::class, + [ + Capability::INPUT_TEXT, + Capability::INPUT_IMAGE, + Capability::INPUT_PDF, + Capability::OUTPUT_TEXT, + ], + ]; + + yield 'google/gemini-2.5-flash-image' => [ + 'google/gemini-2.5-flash-image', + CompletionsModel::class, + [ + Capability::INPUT_IMAGE, + Capability::INPUT_TEXT, + Capability::OUTPUT_IMAGE, + Capability::OUTPUT_TEXT, + ], + ]; + + yield 'openai/text-embedding-3-large' => [ + 'openai/text-embedding-3-large', + EmbeddingsModel::class, + [ + Capability::INPUT_TEXT, + Capability::EMBEDDINGS, + ], + ]; + } + + public function testGetModelThrowsExceptionForEmptyModelName() + { + $catalog = new ModelCatalog(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model name cannot be empty.'); + + // @phpstan-ignore argument.type (testing invalid input) + $catalog->getModel(''); + } + + public function testGetModelReturnsDefaultModelForUnknownModel() + { + $catalog = new ModelCatalog(); + + $model = $catalog->getModel('unknown/model'); + + $this->assertInstanceOf(Model::class, $model); + $this->assertInstanceOf(CompletionsModel::class, $model); + $this->assertSame('unknown/model', $model->getName()); + $this->assertSame([], $model->getCapabilities()); + } + + public function testGetModelWithPreset() + { + $catalog = new ModelCatalog(); + + $model = $catalog->getModel('@preset/my-custom-preset'); + + $this->assertInstanceOf(Model::class, $model); + $this->assertInstanceOf(CompletionsModel::class, $model); + $this->assertSame('@preset/my-custom-preset', $model->getName()); + $this->assertSame(Capability::cases(), $model->getCapabilities()); + } + + public function testGetModelWithModifier() + { + $catalog = new ModelCatalog(); + + $model = $catalog->getModel('deepseek/deepseek-v3.1-terminus:exacto'); + + $this->assertInstanceOf(Model::class, $model); + $this->assertInstanceOf(CompletionsModel::class, $model); + $this->assertSame('deepseek/deepseek-v3.1-terminus:exacto', $model->getName()); + $this->assertSame([Capability::INPUT_TEXT, Capability::OUTPUT_TEXT], $model->getCapabilities()); + } + + /** + * @param array}> $additionalModels + */ + #[DataProvider('additionalModelsProvider')] + public function testConstructorWithAdditionalModels(array $additionalModels, string $modelName, string $expectedClass) + { + $catalog = new ModelCatalog($additionalModels); + + $model = $catalog->getModel($modelName); + + $this->assertInstanceOf($expectedClass, $model); + } + + /** + * @return iterable}>, string, string}> + */ + public static function additionalModelsProvider(): iterable + { + yield 'custom completions model' => [ + [ + 'custom/my-model' => [ + 'class' => CompletionsModel::class, + 'capabilities' => [Capability::INPUT_TEXT, Capability::OUTPUT_TEXT], + ], + ], + 'custom/my-model', + CompletionsModel::class, + ]; + + yield 'custom embeddings model' => [ + [ + 'custom/my-embedding' => [ + 'class' => EmbeddingsModel::class, + 'capabilities' => [Capability::INPUT_TEXT, Capability::EMBEDDINGS], + ], + ], + 'custom/my-embedding', + EmbeddingsModel::class, + ]; + } + + public function testGetModelThrowsExceptionForUnknownModel() + { + // Override parent test because OpenRouter catalog does NOT throw exception + // for unknown models - it creates a default model instead + $this->markTestSkipped('OpenRouter ModelCatalog creates default models for unknown model names.'); + } + + protected function createModelCatalog(): ModelCatalogInterface + { + return new ModelCatalog(); + } +} diff --git a/src/platform/tests/Bridge/OpenRouter/PlatformFactoryTest.php b/src/platform/tests/Bridge/OpenRouter/PlatformFactoryTest.php new file mode 100644 index 000000000..a392cb0e3 --- /dev/null +++ b/src/platform/tests/Bridge/OpenRouter/PlatformFactoryTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\OpenRouter; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\OpenRouter\PlatformFactory; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Component\HttpClient\MockHttpClient; + +/** + * @author Oskar Stark + */ +final class PlatformFactoryTest extends TestCase +{ + public function testItCreatesPlatformWithDefaultSettings() + { + $platform = PlatformFactory::create('test-api-key'); + + $this->assertInstanceOf(Platform::class, $platform); + } + + public function testItCreatesPlatformWithCustomHttpClient() + { + $httpClient = new MockHttpClient(); + $platform = PlatformFactory::create('test-api-key', $httpClient); + + $this->assertInstanceOf(Platform::class, $platform); + } + + public function testItCreatesPlatformWithEventSourceHttpClient() + { + $httpClient = new EventSourceHttpClient(new MockHttpClient()); + $platform = PlatformFactory::create('test-api-key', $httpClient); + + $this->assertInstanceOf(Platform::class, $platform); + } +}