[Platform] Split bridges into dedicated packages

This commit is contained in:
Oskar Stark
2025-12-09 21:32:18 +01:00
parent d5e34d2102
commit 97a8e4ae2b
8 changed files with 645 additions and 0 deletions

12
README.md Normal file
View File

@@ -0,0 +1,12 @@
Generic Platform
================
Generic platform bridge for Symfony AI.
Resources
---------
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/ai/issues) and
[send Pull Requests](https://github.com/symfony/ai/pulls)
in the [main Symfony AI repository](https://github.com/symfony/ai)

View File

@@ -0,0 +1,95 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Platform\Bridge\Generic\Tests\Completions;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\Generic\Completions\ModelClient;
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class ModelClientTest extends TestCase
{
public function testItIsSupportingTheCorrectModel()
{
$modelClient = new ModelClient(new MockHttpClient(), 'http://localhost:8000');
$this->assertTrue($modelClient->supports(new CompletionsModel('gpt-4o')));
}
public function testItIsExecutingTheCorrectRequest()
{
$resultCallback = static function (string $method, string $url, array $options): HttpResponse {
self::assertSame('POST', $method);
self::assertSame('http://localhost:8000/v1/chat/completions', $url);
self::assertSame('Authorization: Bearer sk-valid-api-key', $options['normalized_headers']['authorization'][0]);
self::assertSame('{"temperature":1,"model":"gpt-4o","messages":[{"role":"user","content":"test message"}]}', $options['body']);
return new MockResponse();
};
$httpClient = new MockHttpClient([$resultCallback]);
$modelClient = new ModelClient($httpClient, 'http://localhost:8000', 'sk-valid-api-key');
$modelClient->request(new CompletionsModel('gpt-4o'), ['model' => 'gpt-4o', 'messages' => [['role' => 'user', 'content' => 'test message']]], ['temperature' => 1]);
}
public function testItIsExecutingTheCorrectRequestWithArrayPayload()
{
$resultCallback = static function (string $method, string $url, array $options): HttpResponse {
self::assertSame('POST', $method);
self::assertSame('http://localhost:8000/v1/chat/completions', $url);
self::assertSame('Authorization: Bearer sk-valid-api-key', $options['normalized_headers']['authorization'][0]);
self::assertSame('{"temperature":0.7,"model":"gpt-4o","messages":[{"role":"user","content":"Hello"}]}', $options['body']);
return new MockResponse();
};
$httpClient = new MockHttpClient([$resultCallback]);
$modelClient = new ModelClient($httpClient, 'http://localhost:8000', 'sk-valid-api-key');
$modelClient->request(new CompletionsModel('gpt-4o'), ['model' => 'gpt-4o', 'messages' => [['role' => 'user', 'content' => 'Hello']]], ['temperature' => 0.7]);
}
#[TestWith(['https://api.inference.eu', 'https://api.inference.eu/v1/chat/completions'])]
#[TestWith(['https://api.inference.com', 'https://api.inference.com/v1/chat/completions'])]
public function testItUsesCorrectBaseUrl(string $baseUrl, string $expectedUrl)
{
$resultCallback = static function (string $method, string $url, array $options) use ($expectedUrl): HttpResponse {
self::assertSame('POST', $method);
self::assertSame($expectedUrl, $url);
self::assertSame('Authorization: Bearer sk-valid-api-key', $options['normalized_headers']['authorization'][0]);
return new MockResponse();
};
$httpClient = new MockHttpClient([$resultCallback]);
$modelClient = new ModelClient($httpClient, $baseUrl, 'sk-valid-api-key');
$modelClient->request(new CompletionsModel('gpt-4o'), ['messages' => []]);
}
#[TestWith(['/custom/path', 'https://api.inference.com/custom/path'])]
#[TestWith(['/v1/alternative/endpoint', 'https://api.inference.com/v1/alternative/endpoint'])]
public function testsItUsesCorrectPathIfProvided(string $path, string $expectedUrl)
{
$resultCallback = static function (string $method, string $url, array $options) use ($expectedUrl): HttpResponse {
self::assertSame('POST', $method);
self::assertSame($expectedUrl, $url);
self::assertSame('Authorization: Bearer sk-valid-api-key', $options['normalized_headers']['authorization'][0]);
return new MockResponse();
};
$httpClient = new MockHttpClient([$resultCallback]);
$modelClient = new ModelClient($httpClient, 'https://api.inference.com', 'sk-valid-api-key', $path);
$modelClient->request(new CompletionsModel('gpt-4o'), ['messages' => []]);
}
}

View File

@@ -0,0 +1,248 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Platform\Bridge\Generic\Tests\Completions;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\Generic\Completions\ResultConverter;
use Symfony\AI\Platform\Exception\AuthenticationException;
use Symfony\AI\Platform\Exception\BadRequestException;
use Symfony\AI\Platform\Exception\ContentFilterException;
use Symfony\AI\Platform\Exception\RuntimeException;
use Symfony\AI\Platform\Result\ChoiceResult;
use Symfony\AI\Platform\Result\RawHttpResult;
use Symfony\AI\Platform\Result\TextResult;
use Symfony\AI\Platform\Result\ToolCallResult;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class ResultConverterTest extends TestCase
{
public function testConvertTextResult()
{
$converter = new ResultConverter();
$httpResponse = self::createMock(ResponseInterface::class);
$httpResponse->method('toArray')->willReturn([
'choices' => [
[
'message' => [
'role' => 'assistant',
'content' => 'Hello world',
],
'finish_reason' => 'stop',
],
],
]);
$result = $converter->convert(new RawHttpResult($httpResponse));
$this->assertInstanceOf(TextResult::class, $result);
$this->assertSame('Hello world', $result->getContent());
}
public function testConvertToolCallResult()
{
$converter = new ResultConverter();
$httpResponse = self::createMock(ResponseInterface::class);
$httpResponse->method('toArray')->willReturn([
'choices' => [
[
'message' => [
'role' => 'assistant',
'content' => null,
'tool_calls' => [
[
'id' => 'call_123',
'type' => 'function',
'function' => [
'name' => 'test_function',
'arguments' => '{"arg1": "value1"}',
],
],
],
],
'finish_reason' => 'tool_calls',
],
],
]);
$result = $converter->convert(new RawHttpResult($httpResponse));
$this->assertInstanceOf(ToolCallResult::class, $result);
$toolCalls = $result->getContent();
$this->assertCount(1, $toolCalls);
$this->assertSame('call_123', $toolCalls[0]->getId());
$this->assertSame('test_function', $toolCalls[0]->getName());
$this->assertSame(['arg1' => 'value1'], $toolCalls[0]->getArguments());
}
public function testConvertMultipleChoices()
{
$converter = new ResultConverter();
$httpResponse = self::createMock(ResponseInterface::class);
$httpResponse->method('toArray')->willReturn([
'choices' => [
[
'message' => [
'role' => 'assistant',
'content' => 'Choice 1',
],
'finish_reason' => 'stop',
],
[
'message' => [
'role' => 'assistant',
'content' => 'Choice 2',
],
'finish_reason' => 'stop',
],
],
]);
$result = $converter->convert(new RawHttpResult($httpResponse));
$this->assertInstanceOf(ChoiceResult::class, $result);
$choices = $result->getContent();
$this->assertCount(2, $choices);
$this->assertSame('Choice 1', $choices[0]->getContent());
$this->assertSame('Choice 2', $choices[1]->getContent());
}
public function testContentFilterException()
{
$converter = new ResultConverter();
$httpResponse = self::createMock(ResponseInterface::class);
$httpResponse->expects($this->exactly(1))
->method('toArray')
->willReturnCallback(function ($throw = true) {
if ($throw) {
throw new class extends \Exception implements ClientExceptionInterface {
public function getResponse(): ResponseInterface
{
throw new RuntimeException('Not implemented');
}
};
}
return [
'error' => [
'code' => 'content_filter',
'message' => 'Content was filtered',
],
];
});
$this->expectException(ContentFilterException::class);
$this->expectExceptionMessage('Content was filtered');
$converter->convert(new RawHttpResult($httpResponse));
}
public function testThrowsAuthenticationExceptionOnInvalidApiKey()
{
$converter = new ResultConverter();
$httpResponse = self::createMock(ResponseInterface::class);
$httpResponse->method('getStatusCode')->willReturn(401);
$httpResponse->method('getContent')->willReturn(json_encode([
'error' => [
'message' => 'Invalid API key provided: sk-invalid',
],
]));
$this->expectException(AuthenticationException::class);
$this->expectExceptionMessage('Invalid API key provided: sk-invalid');
$converter->convert(new RawHttpResult($httpResponse));
}
public function testThrowsExceptionWhenNoChoices()
{
$converter = new ResultConverter();
$httpResponse = self::createMock(ResponseInterface::class);
$httpResponse->method('toArray')->willReturn([]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Response does not contain choices');
$converter->convert(new RawHttpResult($httpResponse));
}
public function testThrowsExceptionForUnsupportedFinishReason()
{
$converter = new ResultConverter();
$httpResponse = self::createMock(ResponseInterface::class);
$httpResponse->method('toArray')->willReturn([
'choices' => [
[
'message' => [
'role' => 'assistant',
'content' => 'Test content',
],
'finish_reason' => 'unsupported_reason',
],
],
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Unsupported finish reason "unsupported_reason"');
$converter->convert(new RawHttpResult($httpResponse));
}
public function testThrowsBadRequestExceptionOnBadRequestResponse()
{
$converter = new ResultConverter();
$httpResponse = self::createMock(ResponseInterface::class);
$httpResponse->method('getStatusCode')->willReturn(400);
$httpResponse->method('getContent')->willReturn(json_encode([
'error' => [
'message' => 'Bad Request: invalid parameters',
],
]));
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('Bad Request: invalid parameters');
$converter->convert(new RawHttpResult($httpResponse));
}
public function testThrowsBadRequestExceptionOnBadRequestResponseWithNoResponseBody()
{
$converter = new ResultConverter();
$httpResponse = self::createMock(ResponseInterface::class);
$httpResponse->method('getStatusCode')->willReturn(400);
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('Bad Request');
$converter->convert(new RawHttpResult($httpResponse));
}
public function testThrowsDetailedErrorException()
{
$converter = new ResultConverter();
$httpResponse = self::createMock(ResponseInterface::class);
$httpResponse->method('toArray')->willReturn([
'error' => [
'code' => 'invalid_request_error',
'type' => 'invalid_request',
'param' => 'model',
'message' => 'The model `gpt-5` does not exist',
],
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Error "invalid_request_error"-invalid_request (model): "The model `gpt-5` does not exist".');
$converter->convert(new RawHttpResult($httpResponse));
}
}

View File

@@ -0,0 +1,118 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Platform\Bridge\Generic\Tests\Embeddings;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
use Symfony\AI\Platform\Bridge\Generic\Embeddings\ModelClient;
use Symfony\AI\Platform\Bridge\Generic\EmbeddingsModel;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse;
/**
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class ModelClientTest extends TestCase
{
public function testItIsSupportingTheCorrectModel()
{
$modelClient = new ModelClient(new MockHttpClient(), 'http://localhost:8000');
$this->assertTrue($modelClient->supports(new EmbeddingsModel('text-embedding-3-small')));
}
public function testItIsNotSupportingTheIncorrectModel()
{
$modelClient = new ModelClient(new MockHttpClient(), 'http://localhost:8000');
$this->assertFalse($modelClient->supports(new CompletionsModel('gpt-4o-mini')));
}
public function testItIsExecutingTheCorrectRequest()
{
$resultCallback = static function (string $method, string $url, array $options): HttpResponse {
self::assertSame('POST', $method);
self::assertSame('http://localhost:8000/v1/embeddings', $url);
self::assertSame('Authorization: Bearer sk-api-key', $options['normalized_headers']['authorization'][0]);
self::assertSame('{"model":"text-embedding-3-small","input":"test text"}', $options['body']);
return new MockResponse();
};
$httpClient = new MockHttpClient([$resultCallback]);
$modelClient = new ModelClient($httpClient, 'http://localhost:8000', 'sk-api-key');
$modelClient->request(new EmbeddingsModel('text-embedding-3-small'), 'test text');
}
public function testItIsExecutingTheCorrectRequestWithCustomOptions()
{
$resultCallback = static function (string $method, string $url, array $options): HttpResponse {
self::assertSame('POST', $method);
self::assertSame('http://localhost:8000/v1/embeddings', $url);
self::assertSame('Authorization: Bearer sk-api-key', $options['normalized_headers']['authorization'][0]);
self::assertSame('{"dimensions":256,"model":"text-embedding-3-large","input":"test text"}', $options['body']);
return new MockResponse();
};
$httpClient = new MockHttpClient([$resultCallback]);
$modelClient = new ModelClient($httpClient, 'http://localhost:8000', 'sk-api-key');
$modelClient->request(new EmbeddingsModel('text-embedding-3-large'), 'test text', ['dimensions' => 256]);
}
public function testItIsExecutingTheCorrectRequestWithArrayInput()
{
$resultCallback = static function (string $method, string $url, array $options): HttpResponse {
self::assertSame('POST', $method);
self::assertSame('http://localhost:8000/v1/embeddings', $url);
self::assertSame('Authorization: Bearer sk-api-key', $options['normalized_headers']['authorization'][0]);
self::assertSame('{"model":"text-embedding-3-small","input":["text1","text2","text3"]}', $options['body']);
return new MockResponse();
};
$httpClient = new MockHttpClient([$resultCallback]);
$modelClient = new ModelClient($httpClient, 'http://localhost:8000', 'sk-api-key');
$modelClient->request(new EmbeddingsModel('text-embedding-3-small'), ['text1', 'text2', 'text3']);
}
#[TestWith(['https://api.inference.eu', 'https://api.inference.eu/v1/embeddings'])]
#[TestWith(['https://api.inference.com', 'https://api.inference.com/v1/embeddings'])]
public function testItUsesCorrectBaseUrl(string $baseUrl, string $expectedUrl)
{
$resultCallback = static function (string $method, string $url, array $options) use ($expectedUrl): HttpResponse {
self::assertSame('POST', $method);
self::assertSame($expectedUrl, $url);
self::assertSame('Authorization: Bearer sk-api-key', $options['normalized_headers']['authorization'][0]);
return new MockResponse();
};
$httpClient = new MockHttpClient([$resultCallback]);
$modelClient = new ModelClient($httpClient, $baseUrl, 'sk-api-key');
$modelClient->request(new EmbeddingsModel('text-embedding-3-small'), 'test input');
}
#[TestWith(['/custom/path', 'https://api.inference.com/custom/path'])]
#[TestWith(['/v1/alternative/endpoint', 'https://api.inference.com/v1/alternative/endpoint'])]
public function testsItUsesCorrectPathIfProvided(string $path, string $expectedUrl)
{
$resultCallback = static function (string $method, string $url, array $options) use ($expectedUrl): HttpResponse {
self::assertSame('POST', $method);
self::assertSame($expectedUrl, $url);
self::assertSame('Authorization: Bearer sk-api-key', $options['normalized_headers']['authorization'][0]);
return new MockResponse();
};
$httpClient = new MockHttpClient([$resultCallback]);
$modelClient = new ModelClient($httpClient, 'https://api.inference.com', 'sk-api-key', $path);
$modelClient->request(new EmbeddingsModel('text-embedding-3-small'), 'test input');
}
}

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\AI\Platform\Bridge\Generic\Tests\Embeddings;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\Generic\Embeddings\ResultConverter;
use Symfony\AI\Platform\Result\RawHttpResult;
use Symfony\Contracts\HttpClient\ResponseInterface;
class ResultConverterTest extends TestCase
{
public function testItConvertsAResponseToAVectorResult()
{
$result = $this->createStub(ResponseInterface::class);
$result
->method('toArray')
->willReturn(json_decode($this->getEmbeddingStub(), true));
$vectorResult = (new ResultConverter())->convert(new RawHttpResult($result));
$convertedContent = $vectorResult->getContent();
$this->assertCount(2, $convertedContent);
$this->assertSame([0.3, 0.4, 0.4], $convertedContent[0]->getData());
$this->assertSame([0.0, 0.0, 0.2], $convertedContent[1]->getData());
}
private function getEmbeddingStub(): string
{
return <<<'JSON'
{
"object": "list",
"data": [
{
"object": "embedding",
"index": 0,
"embedding": [0.3, 0.4, 0.4]
},
{
"object": "embedding",
"index": 1,
"embedding": [0.0, 0.0, 0.2]
}
]
}
JSON;
}
}

60
composer.json Normal file
View File

@@ -0,0 +1,60 @@
{
"name": "symfony/ai-generic-platform",
"description": "Generic platform bridge for Symfony AI",
"license": "MIT",
"type": "symfony-ai-platform",
"keywords": [
"ai",
"bridge",
"generic",
"platform"
],
"authors": [
{
"name": "Christopher Hertel",
"email": "mail@christopher-hertel.de"
},
{
"name": "Oskar Stark",
"email": "oskarstark@googlemail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"require": {
"php": ">=8.2",
"symfony/ai-platform": "@dev",
"symfony/http-client": "^7.3|^8.0"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^11.5.46"
},
"minimum-stability": "dev",
"autoload": {
"psr-4": {
"Symfony\\AI\\Platform\\Bridge\\Generic\\": ""
}
},
"autoload-dev": {
"psr-4": {
"Symfony\\AI\\PHPStan\\": "../../../../../.phpstan/",
"Symfony\\AI\\Platform\\Bridge\\Generic\\Tests\\": "Tests/"
}
},
"config": {
"sort-packages": true
},
"extra": {
"branch-alias": {
"dev-main": "0.x-dev"
},
"thanks": {
"name": "symfony/ai",
"url": "https://github.com/symfony/ai"
}
}
}

23
phpstan.dist.neon Normal file
View File

@@ -0,0 +1,23 @@
includes:
- ../../../../../.phpstan/extension.neon
parameters:
level: 6
paths:
- .
- Tests/
excludePaths:
- vendor/
treatPhpDocTypesAsCertain: false
ignoreErrors:
-
message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#"
reportUnmatched: false
-
identifier: missingType.iterableValue
path: Tests/*
reportUnmatched: false
-
identifier: 'symfonyAi.forbidNativeException'
path: Tests/*
reportUnmatched: false

32
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="vendor/autoload.php"
failOnDeprecation="true"
failOnRisky="true"
failOnWarning="true"
>
<php>
<ini name="error_reporting" value="-1" />
</php>
<testsuites>
<testsuite name="Symfony AI Generic Platform Test Suite">
<directory>./Tests/</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true">
<include>
<directory>./</directory>
</include>
<exclude>
<directory>./Resources</directory>
<directory>./Tests</directory>
<directory>./vendor</directory>
</exclude>
</source>
</phpunit>