From df10bc776fe41fcaf60b545c1438794b203f64d4 Mon Sep 17 00:00:00 2001 From: Frank Berger Date: Tue, 6 Oct 2026 17:39:54 +0200 Subject: [PATCH 1/3] [FEATURE] Add capabiliy to modify the used HTTP Client Tasks: * Add httpClient option to the ProviderConfiguration * Inject the httpClient into the Bridge Factory * Add Documentation * Add Unittests Resolves: #33 Releases: main --- .../Domain/Model/ProviderConfiguration.php | 2 + .../SymfonyAi/SymfonyAiPlatformAdapter.php | 4 +- Documentation/HttpClient.md | 47 +++++ .../Fixtures/HttpClientMiddleware.php | 42 +++++ .../Middleware/HttpClientMiddlewareTest.php | 168 ++++++++++++++++++ 5 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 Documentation/HttpClient.md create mode 100644 Tests/Unit/Middleware/Fixtures/HttpClientMiddleware.php create mode 100644 Tests/Unit/Middleware/HttpClientMiddlewareTest.php diff --git a/Classes/Domain/Model/ProviderConfiguration.php b/Classes/Domain/Model/ProviderConfiguration.php index 0377741..271fe19 100644 --- a/Classes/Domain/Model/ProviderConfiguration.php +++ b/Classes/Domain/Model/ProviderConfiguration.php @@ -13,6 +13,7 @@ namespace B13\Aim\Domain\Model; use B13\Aim\Provider\EndpointCredential; +use Symfony\Contracts\HttpClient\HttpClientInterface; /** * Represents a provider configuration record from tx_aim_configuration. @@ -45,6 +46,7 @@ final class ProviderConfiguration public readonly int $judgeConfigurationUid; public readonly string $gradingRubric; public readonly string $systemPromptAddition; + public ?HttpClientInterface $httpClient = null; public function __construct( public readonly array $row, diff --git a/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php b/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php index cbe236f..a0089ab 100644 --- a/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php +++ b/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php @@ -104,7 +104,7 @@ class SymfonyAiPlatformAdapter implements public function __construct( private readonly string $factoryClass, private readonly string $factoryParam = 'apiKey', - private readonly CredentialRedactor $redactor = new CredentialRedactor(), + private readonly CredentialRedactor $redactor = new CredentialRedactor() ) { $this->maxTokensKey = self::resolveMaxTokensKey($factoryClass); $parameters = self::resolveFactoryParameterNames($factoryClass); @@ -401,6 +401,8 @@ private function getPlatform(ProviderConfiguration $config): ProviderInterface private function buildFactoryArguments(ProviderConfiguration $config): array { $arguments = []; + $arguments['httpClient'] = $config->httpClient; + if ($this->factoryAcceptsEndpoint && $config->endpoint !== '') { $arguments[$this->endpointParam] = $config->getRequestEndpoint(); } diff --git a/Documentation/HttpClient.md b/Documentation/HttpClient.md new file mode 100644 index 0000000..d7d35bb --- /dev/null +++ b/Documentation/HttpClient.md @@ -0,0 +1,47 @@ +# Modifying the HTTP client + +How to swap or tune the HTTP client Symfony AI uses for provider requests, for example to raise the timeout. + +[Back to the README](../README.md) + +## How the client is chosen + +By default, Symfony AI uses an `EventSourceHttpClient`, which each bridge's factory class instantiates and configures for its own connection. A pre-configured client can be passed to the constructor of the bridge class instead. + +## Setting your own client + +`B13\Aim\Domain\Model\ProviderConfiguration` holds the settings of the chosen provider, taken from the database record or the site settings. Its `httpClient` property is `null` by default. If you set it, it must be an implementation of `Symfony\Contracts\HttpClient\HttpClientInterface`, and Symfony AI uses it instead of creating its own. + +## Example: raising the timeout + +A common use case is a longer timeout, which is easiest to do in an AiM middleware (see [Custom Middleware](Pipeline.md#custom-middleware)). + +The middleware below runs early in the pipeline and checks whether an `httpClient` has already been set. If not, it creates a new `EventSourceHttpClient` with a timeout of 900 seconds. If one exists, it sets the same timeout on the existing client: + +```php +use B13\Aim\Attribute\AsAiMiddleware; +use B13\Aim\Middleware\AiMiddlewareInterface; +use Symfony\Component\HttpClient\EventSourceHttpClient; + +#[AsAiMiddleware(priority: 900)] +class HttpClientMiddleware implements AiMiddlewareInterface +{ + public function process( + AiRequestInterface $request, + AiProviderInterface $provider, + ProviderConfiguration $configuration, + AiMiddlewareHandler $next, + ): TextResponse { + $configuration->httpClient = ($configuration->httpClient ?? new EventSourceHttpClient()) + ->withOptions(['timeout' => 900.0]); + + return $next->handle($request, $provider, $configuration); + } +} +``` + +Priority `900` places it above every built-in middleware, so all of them, including `RetryWithFallbackMiddleware`, work with the modified client. + +This way other global HTTP client settings can be set, like proxy or SOCKS5 settings. + +Another usecase as well could be to mock HTTP requests for testing to prevent actual HTTP requests from being made. diff --git a/Tests/Unit/Middleware/Fixtures/HttpClientMiddleware.php b/Tests/Unit/Middleware/Fixtures/HttpClientMiddleware.php new file mode 100644 index 0000000..1a6d3b1 --- /dev/null +++ b/Tests/Unit/Middleware/Fixtures/HttpClientMiddleware.php @@ -0,0 +1,42 @@ +httpClient = ($configuration->httpClient ?? new EventSourceHttpClient()) + ->withOptions(['timeout' => 900.0]); + + return $next->handle($request, $provider, $configuration); + } +} diff --git a/Tests/Unit/Middleware/HttpClientMiddlewareTest.php b/Tests/Unit/Middleware/HttpClientMiddlewareTest.php new file mode 100644 index 0000000..e06ae64 --- /dev/null +++ b/Tests/Unit/Middleware/HttpClientMiddlewareTest.php @@ -0,0 +1,168 @@ + 1, + 'ai_provider' => 'openai', + 'title' => 'Test', + 'api_key' => 'sk-test', + 'model' => 'gpt-4o', + ]); + } + + /** + * @return array{0: TextResponse, 1: ProviderConfiguration} + */ + private function process(ProviderConfiguration $config): array + { + $response = new TextResponse('hello', new AiUsageStatistics()); + $seen = null; + $next = new AiMiddlewareHandler(static function ($request, $provider, ProviderConfiguration $configuration) use ($response, &$seen) { + $seen = $configuration; + return $response; + }); + + $result = (new HttpClientMiddleware())->process( + new TextGenerationRequest(configuration: $config, prompt: 'Hi'), + $this->createMock(AiProviderInterface::class), + $config, + $next + ); + + return [$result, $seen]; + } + + #[Test] + public function runsEarlyInThePipeline(): void + { + $attributes = (new \ReflectionClass(HttpClientMiddleware::class))->getAttributes(AsAiMiddleware::class); + + self::assertCount(1, $attributes); + self::assertSame(900, $attributes[0]->newInstance()->priority); + } + + #[Test] + public function createsEventSourceHttpClientWhenNoneIsSet(): void + { + $config = $this->createConfig(); + self::assertNull($config->httpClient); + + [, $seen] = $this->process($config); + + self::assertInstanceOf(EventSourceHttpClient::class, $seen->httpClient); + } + + #[Test] + public function passesTheConfigurationAndResponseThroughTheChain(): void + { + $config = $this->createConfig(); + + [$result, $seen] = $this->process($config); + + self::assertSame('hello', $result->content); + self::assertSame($config, $seen); + } + + #[Test] + public function appliesTimeoutOf900SecondsToAnExistingClient(): void + { + $receivedOptions = []; + $mock = new MockHttpClient(static function (string $method, string $url, array $options) use (&$receivedOptions) { + $receivedOptions = $options; + return new MockResponse('{}'); + }); + $config = $this->createConfig(); + $config->httpClient = $mock; + + $this->process($config); + $config->httpClient->request('GET', 'https://example.com/')->getContent(); + + self::assertSame(900.0, (float)$receivedOptions['timeout']); + } + + #[Test] + public function keepsOtherOptionsOfAnExistingClient(): void + { + $receivedOptions = []; + $mock = new MockHttpClient(static function (string $method, string $url, array $options) use (&$receivedOptions) { + $receivedOptions = $options; + return new MockResponse('{}'); + }, 'https://example.com'); + $config = $this->createConfig(); + $config->httpClient = $mock->withOptions(['proxy' => 'socks5://127.0.0.1:9050']); + + $this->process($config); + $config->httpClient->request('GET', '/path')->getContent(); + + self::assertSame(900.0, (float)$receivedOptions['timeout']); + self::assertSame('socks5://127.0.0.1:9050', $receivedOptions['proxy']); + } + + #[Test] + public function doesNotTouchTheOriginalClientInstance(): void + { + $receivedOptions = []; + $mock = new MockHttpClient(static function (string $method, string $url, array $options) use (&$receivedOptions) { + $receivedOptions = $options; + return new MockResponse('{}'); + }); + $config = $this->createConfig(); + $config->httpClient = $mock; + + $this->process($config); + + self::assertNotSame($mock, $config->httpClient); + $mock->request('GET', 'https://example.com/')->getContent(); + self::assertNotSame(900.0, (float)($receivedOptions['timeout'] ?? 0)); + } + + #[Test] + public function mockClientPreventsRealHttpRequests(): void + { + $requested = []; + $mock = new MockHttpClient(static function (string $method, string $url) use (&$requested) { + $requested[] = $method . ' ' . $url; + return new MockResponse('{"ok":true}'); + }); + $config = $this->createConfig(); + $config->httpClient = $mock; + + $this->process($config); + $content = $config->httpClient->request('POST', 'https://api.example.invalid/v1/chat')->getContent(); + + self::assertSame('{"ok":true}', $content); + self::assertSame(['POST https://api.example.invalid/v1/chat'], $requested); + } +} From afa0cf0d554b11e4320545b3589600a93f4ae44b Mon Sep 17 00:00:00 2001 From: Frank Berger Date: Tue, 6 Oct 2026 17:58:06 +0200 Subject: [PATCH 2/3] [FEATURE] Add dev dependency symfony/http-client Resolves: #33 Releases: main --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 8fa186b..ee32c39 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "phpunit/phpunit": "^11.0", "typo3/testing-framework": "^9.0", "symfony/ai-platform": "^0.11", + "symfony/http-client": "^6.4", "typo3/cms-install": "^12.4 || ^13.4 || ^14.0", "phpstan/phpstan": "^2.2", "saschaegerer/phpstan-typo3": "^3.1", From 32d41b4e9ff7be78cb900f2f94aacfe27fee0faa Mon Sep 17 00:00:00 2001 From: Frank Berger Date: Tue, 6 Oct 2026 18:11:20 +0200 Subject: [PATCH 3/3] [FEATURE] Check if httpClient has been set to something implementing HttpClientInterface Resolves: #33 Releases: main --- Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php b/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php index a0089ab..ea707e3 100644 --- a/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php +++ b/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php @@ -55,6 +55,7 @@ use Symfony\AI\Platform\TokenUsage\TokenUsageInterface; use Symfony\AI\Platform\Tool\ExecutionReference; use Symfony\AI\Platform\Tool\Tool as SymfonyTool; +use Symfony\Contracts\HttpClient\HttpClientInterface; use TYPO3\CMS\Core\Log\LogManager; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -401,7 +402,9 @@ private function getPlatform(ProviderConfiguration $config): ProviderInterface private function buildFactoryArguments(ProviderConfiguration $config): array { $arguments = []; - $arguments['httpClient'] = $config->httpClient; + if ($config->httpClient instanceof HttpClientInterface) { + $arguments['httpClient'] = $config->httpClient; + } if ($this->factoryAcceptsEndpoint && $config->endpoint !== '') { $arguments[$this->endpointParam] = $config->getRequestEndpoint();