From 5a35627376fafda82373be57bbd773bc7da3d0ab Mon Sep 17 00:00:00 2001 From: Oliver Bartsch Date: Sat, 26 Sep 2026 08:17:28 +0200 Subject: [PATCH] [BUGFIX] Register a Symfony AI bridge whose catalog starts empty A bridge was only registered once the compiler pass had found models for it, either in a catalog it could read or in one it could tell apart as needing runtime context. symfony/ai-open-responses-platform is neither. Its ModelCatalog is built without arguments and starts out empty, because models are registered by whoever configures the bridge and the provider falls back to a catalog accepting any model name. The bridge was dropped, so the one way to reach a self-hosted OpenAI-compatible endpoint never appeared in the provider list. A missing ModelCatalog is now what marks a package as not being a bridge: Symfony AI builds no provider without one. An empty model list is left to the endpoint, which the model field already asks for its models once the record names it. Resolves: #35 --- .../SymfonyAiCompilerPass.php | 64 ++++++------- .../EmptyModelCatalogTest.php | 95 +++++++++++++++++++ .../Fixtures/Catalogless/Factory.php | 26 +++++ .../Fixtures/EmptyCatalog/Factory.php | 31 ++++++ .../Fixtures/EmptyCatalog/ModelCatalog.php | 36 +++++++ .../Fixtures/ThrowingCatalog/Factory.php | 21 ++++ .../Fixtures/ThrowingCatalog/ModelCatalog.php | 30 ++++++ 7 files changed, 271 insertions(+), 32 deletions(-) create mode 100644 Tests/Unit/DependencyInjection/EmptyModelCatalogTest.php create mode 100644 Tests/Unit/DependencyInjection/Fixtures/Catalogless/Factory.php create mode 100644 Tests/Unit/DependencyInjection/Fixtures/EmptyCatalog/Factory.php create mode 100644 Tests/Unit/DependencyInjection/Fixtures/EmptyCatalog/ModelCatalog.php create mode 100644 Tests/Unit/DependencyInjection/Fixtures/ThrowingCatalog/Factory.php create mode 100644 Tests/Unit/DependencyInjection/Fixtures/ThrowingCatalog/ModelCatalog.php 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 @@ +