Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 32 additions & 32 deletions Classes/DependencyInjection/SymfonyAiCompilerPass.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
95 changes: 95 additions & 0 deletions Tests/Unit/DependencyInjection/EmptyModelCatalogTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

declare(strict_types=1);

/*
* This file is part of TYPO3 CMS-based extension "aim" by b13.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*/

namespace B13\Aim\Tests\Unit\DependencyInjection;

use B13\Aim\DependencyInjection\SymfonyAiCompilerPass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

/**
* A bridge was only registered once the 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 catalog 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 only way to reach a
* self-hosted OpenAI-compatible endpoint never appeared in the provider list.
* Reported as #35.
*/
final class EmptyModelCatalogTest extends TestCase
{
private const FIXTURES = 'B13\\Aim\\Tests\\Unit\\DependencyInjection\\Fixtures\\';

#[Test]
public function aBridgeWhoseCatalogStartsEmptyIsRegistered(): void
{
$bridge = $this->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<string, mixed>|null
*/
private function buildBridge(string $packageName, string $namespace): ?array
{
$method = new \ReflectionMethod(SymfonyAiCompilerPass::class, 'buildBridgeDefinition');

return $method->invoke(new SymfonyAiCompilerPass(), [
'name' => $packageName,
'namespace' => $namespace,
]);
}
}
26 changes: 26 additions & 0 deletions Tests/Unit/DependencyInjection/Fixtures/Catalogless/Factory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

/*
* This file is part of TYPO3 CMS-based extension "aim" by b13.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*/

namespace B13\Aim\Tests\Unit\DependencyInjection\Fixtures\Catalogless;

/**
* A package of the bridge Composer type carrying a Factory but no catalog
* beside it. Symfony AI's Provider cannot be built without a catalog, so this
* is not a bridge whatever its package type claims.
*/
final class Factory
{
public static function createProvider(string $apiKey): object
{
return new \stdClass();
}
}
31 changes: 31 additions & 0 deletions Tests/Unit/DependencyInjection/Fixtures/EmptyCatalog/Factory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

/*
* This file is part of TYPO3 CMS-based extension "aim" by b13.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*/

namespace B13\Aim\Tests\Unit\DependencyInjection\Fixtures\EmptyCatalog;

/**
* Shaped like symfony/ai-open-responses-platform: the endpoint is the required
* argument, the credential is optional, and the catalog handed to the provider
* at runtime accepts any model name.
*/
final class Factory
{
public static function createProvider(string $baseUrl, ?string $apiKey = null): object
{
return new class() {
public function getName(): string
{
return 'openresponses';
}
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

/*
* This file is part of TYPO3 CMS-based extension "aim" by b13.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*/

namespace B13\Aim\Tests\Unit\DependencyInjection\Fixtures\EmptyCatalog;

/**
* A catalog that can be built without runtime context but knows no models of
* its own, the shape symfony/ai-open-responses-platform ships: models are
* registered explicitly by whoever configures the bridge.
*/
final class ModelCatalog
{
/**
* @param array<string, array{class: class-string, capabilities: list<object>}> $models
*/
public function __construct(private readonly array $models = [])
{
}

/**
* @return array<string, array{class: class-string, capabilities: list<object>}>
*/
public function getModels(): array
{
return $this->models;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

/*
* This file is part of TYPO3 CMS-based extension "aim" by b13.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*/

namespace B13\Aim\Tests\Unit\DependencyInjection\Fixtures\ThrowingCatalog;

final class Factory
{
public static function createProvider(string $baseUrl, ?string $apiKey = null): object
{
return new \stdClass();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

/*
* This file is part of TYPO3 CMS-based extension "aim" by b13.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*/

namespace B13\Aim\Tests\Unit\DependencyInjection\Fixtures\ThrowingCatalog;

/**
* A catalog that looks buildable and then is not. Whatever the reason, it says
* nothing about whether the package is a bridge.
*/
final class ModelCatalog
{
public function __construct()
{
throw new \RuntimeException('This catalog refuses to be built at compile time.', 1790346979);
}

public function getModels(): array
{
return [];
}
}
Loading