diff --git a/Classes/DependencyInjection/SymfonyAiCompilerPass.php b/Classes/DependencyInjection/SymfonyAiCompilerPass.php index 46d108f..31c6790 100644 --- a/Classes/DependencyInjection/SymfonyAiCompilerPass.php +++ b/Classes/DependencyInjection/SymfonyAiCompilerPass.php @@ -221,46 +221,46 @@ private function buildBridgeDefinition(array $package): ?array // Detect factory auth parameter via reflection $factoryParam = $this->detectFactoryParam($factoryClass); - // Read models + capabilities from ModelCatalog. - // - // Some bridges (Ollama, LM Studio, …) ship a ModelCatalog that requires - // runtime context (an HTTP client pointing at the user's endpoint) and - // queries the live server for the model list. We can't do that at - // container-compile time as the endpoint URL lives in a TCA record we - // don't have access to here. For those bridges we register the provider - // with an empty model list. + // Symfony AI builds no provider without a ModelCatalog, so a package + // carrying the bridge package type and nothing beside its Factory is + // not a bridge and has nothing to offer AiM. $catalogClass = $namespace . '\\ModelCatalog'; + if (!class_exists($catalogClass)) { + return null; + } + + // Read models + capabilities from the catalog, when it has any to give. + // + // An empty model list is a normal state, not a reason to skip the + // bridge. Some bridges (Ollama, LM Studio, etc.) ship a ModelCatalog + // that requires runtime context (an HTTP client pointing at the user's + // endpoint) and queries the live server for the model list. Others + // (Open Responses) ship a catalog that is built without arguments and + // starts out empty, because the models of a self-hosted endpoint are + // only known once someone names that endpoint. Either way the URL lives + // in a TCA record this pass has no access to, so those bridges are + // registered with an empty model list and the form asks the configured + // endpoint for its models later. $models = []; $modelCapabilities = []; $features = ['supportsStreaming' => true]; - $catalogIsDynamic = false; - - if (class_exists($catalogClass)) { - $constructor = (new \ReflectionClass($catalogClass))->getConstructor(); - if ($constructor === null || $constructor->getNumberOfRequiredParameters() === 0) { - try { - $catalog = new $catalogClass(); - if (method_exists($catalog, 'getModels')) { - [$models, $modelCapabilities, $features] = $this->extractModelsFromCatalog( - $catalog->getModels(), - $features, - ); - } - } catch (\Throwable) { - // Catalog instantiation failed for an unexpected reason — - // fall through with empty models. + + $constructor = (new \ReflectionClass($catalogClass))->getConstructor(); + if ($constructor === null || $constructor->getNumberOfRequiredParameters() === 0) { + try { + $catalog = new $catalogClass(); + if (method_exists($catalog, 'getModels')) { + [$models, $modelCapabilities, $features] = $this->extractModelsFromCatalog( + $catalog->getModels(), + $features, + ); } - } else { - $catalogIsDynamic = true; + } catch (\Throwable) { + // Why the catalog could not be built says nothing about whether + // the package is a bridge, so it is registered without models. } } - // Skip bridges that have neither a static catalog nor a dynamic one - // (the package matches the naming pattern but isn't a real bridge). - if ($models === [] && !$catalogIsDynamic) { - return null; - } - return [ 'identifier' => $identifier, 'name' => $name, diff --git a/Tests/Unit/DependencyInjection/EmptyModelCatalogTest.php b/Tests/Unit/DependencyInjection/EmptyModelCatalogTest.php new file mode 100644 index 0000000..11ee87e --- /dev/null +++ b/Tests/Unit/DependencyInjection/EmptyModelCatalogTest.php @@ -0,0 +1,95 @@ +buildBridge('symfony/ai-open-responses-platform', self::FIXTURES . 'EmptyCatalog'); + + self::assertIsArray($bridge, 'The bridge was dropped for having no models to offer yet.'); + self::assertSame('openresponses', $bridge['identifier']); + self::assertSame([], $bridge['models'], 'Models come from the configured endpoint, not from the catalog.'); + } + + /** + * The record holds the endpoint, and the model list is fetched from it + * later. Passing it as the credential instead would send the URL as a + * bearer token and leave the bridge without a host to talk to. + */ + #[Test] + public function theEndpointIsRecognisedAsTheArgumentTheBridgeWants(): void + { + $bridge = $this->buildBridge('symfony/ai-open-responses-platform', self::FIXTURES . 'EmptyCatalog'); + + self::assertIsArray($bridge); + self::assertSame('endpoint', $bridge['factoryParam']); + } + + /** + * Why the catalog could not be read says nothing about whether the package + * is a bridge, and a bridge missing from the list is harder to explain than + * one whose model field stays empty. + */ + #[Test] + public function aBridgeWhoseCatalogRefusesToBeBuiltIsStillRegistered(): void + { + $bridge = $this->buildBridge('acme/ai-brittle-platform', self::FIXTURES . 'ThrowingCatalog'); + + self::assertIsArray($bridge); + self::assertSame([], $bridge['models']); + } + + /** + * Symfony AI builds no provider without a catalog, so a package carrying + * the bridge package type and nothing else is not one. + */ + #[Test] + public function aPackageWithoutAnyCatalogIsNotABridge(): void + { + $bridge = $this->buildBridge('acme/ai-pretend-platform', self::FIXTURES . 'Catalogless'); + + self::assertNull($bridge); + } + + /** + * @return array|null + */ + private function buildBridge(string $packageName, string $namespace): ?array + { + $method = new \ReflectionMethod(SymfonyAiCompilerPass::class, 'buildBridgeDefinition'); + + return $method->invoke(new SymfonyAiCompilerPass(), [ + 'name' => $packageName, + 'namespace' => $namespace, + ]); + } +} diff --git a/Tests/Unit/DependencyInjection/Fixtures/Catalogless/Factory.php b/Tests/Unit/DependencyInjection/Fixtures/Catalogless/Factory.php new file mode 100644 index 0000000..41beca1 --- /dev/null +++ b/Tests/Unit/DependencyInjection/Fixtures/Catalogless/Factory.php @@ -0,0 +1,26 @@ +}> $models + */ + public function __construct(private readonly array $models = []) + { + } + + /** + * @return array}> + */ + public function getModels(): array + { + return $this->models; + } +} diff --git a/Tests/Unit/DependencyInjection/Fixtures/ThrowingCatalog/Factory.php b/Tests/Unit/DependencyInjection/Fixtures/ThrowingCatalog/Factory.php new file mode 100644 index 0000000..179343b --- /dev/null +++ b/Tests/Unit/DependencyInjection/Fixtures/ThrowingCatalog/Factory.php @@ -0,0 +1,21 @@ +