Skip to content
Open
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
2 changes: 2 additions & 0 deletions Classes/Domain/Model/ProviderConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -104,7 +105,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);
Expand Down Expand Up @@ -401,6 +402,10 @@ private function getPlatform(ProviderConfiguration $config): ProviderInterface
private function buildFactoryArguments(ProviderConfiguration $config): array
{
$arguments = [];
if ($config->httpClient instanceof HttpClientInterface) {
$arguments['httpClient'] = $config->httpClient;
}

if ($this->factoryAcceptsEndpoint && $config->endpoint !== '') {
$arguments[$this->endpointParam] = $config->getRequestEndpoint();
}
Expand Down
47 changes: 47 additions & 0 deletions Documentation/HttpClient.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 42 additions & 0 deletions Tests/Unit/Middleware/Fixtures/HttpClientMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?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\Middleware\Fixtures;

use B13\Aim\Attribute\AsAiMiddleware;
use B13\Aim\Domain\Model\ProviderConfiguration;
use B13\Aim\Middleware\AiMiddlewareHandler;
use B13\Aim\Middleware\AiMiddlewareInterface;
use B13\Aim\Provider\AiProviderInterface;
use B13\Aim\Request\AiRequestInterface;
use B13\Aim\Response\TextResponse;
use Symfony\Component\HttpClient\EventSourceHttpClient;

/**
* The example middleware from Documentation/HttpClient.md, kept verbatim so
* the documented behavior is covered by a test.
*/
#[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);
}
}
168 changes: 168 additions & 0 deletions Tests/Unit/Middleware/HttpClientMiddlewareTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<?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\Middleware;

use B13\Aim\Attribute\AsAiMiddleware;
use B13\Aim\Domain\Model\ProviderConfiguration;
use B13\Aim\Middleware\AiMiddlewareHandler;
use B13\Aim\Provider\AiProviderInterface;
use B13\Aim\Request\TextGenerationRequest;
use B13\Aim\Response\AiUsageStatistics;
use B13\Aim\Response\TextResponse;
use B13\Aim\Tests\Unit\Middleware\Fixtures\HttpClientMiddleware;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\EventSourceHttpClient;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

/**
* Covers the example in Documentation/HttpClient.md.
*/
final class HttpClientMiddlewareTest extends TestCase
{
private function createConfig(): ProviderConfiguration
{
return new ProviderConfiguration([
'uid' => 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);
}
}
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading