[Platform][amazee.ai] Add bridge

This commit is contained in:
Christophe Jossart
2026-02-13 22:31:09 +01:00
committed by Christopher Hertel
parent e924ac199d
commit 514c354990
26 changed files with 1079 additions and 0 deletions

View File

@@ -203,3 +203,7 @@ OPENSEARCH_ENDPOINT=http://127.0.0.1:9200
# For using OVH
OVH_AI_SECRET_KEY=
# amazee.ai
AMAZEEAI_LLM_KEY=
AMAZEEAI_LLM_API_URL=

View File

@@ -0,0 +1,26 @@
<?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.
*/
use Symfony\AI\Platform\Bridge\AmazeeAi\PlatformFactory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
require_once dirname(__DIR__).'/bootstrap.php';
$platform = PlatformFactory::create(env('AMAZEEAI_LLM_API_URL'), env('AMAZEEAI_LLM_KEY'), http_client());
$messages = new MessageBag(
Message::forSystem('You are a pirate and you write funny.'),
Message::ofUser('What is the Symfony framework?'),
);
$result = $platform->invoke('claude-3-5-sonnet', $messages);
echo $result->asText().\PHP_EOL;

View File

@@ -0,0 +1,24 @@
<?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.
*/
use Symfony\AI\Platform\Bridge\AmazeeAi\PlatformFactory;
require_once dirname(__DIR__).'/bootstrap.php';
$platform = PlatformFactory::create(env('AMAZEEAI_LLM_API_URL'), env('AMAZEEAI_LLM_KEY'), http_client());
$result = $platform->invoke('titan-embed-text-v2:0', <<<TEXT
Once upon a time, there was a country called Japan. It was a beautiful country with a lot of mountains and rivers.
The people of Japan were very kind and hardworking. They loved their country very much and took care of it. The
country was very peaceful and prosperous. The people lived happily ever after.
TEXT);
print_vectors($result);

View File

@@ -0,0 +1,28 @@
<?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.
*/
use Symfony\AI\Platform\Bridge\AmazeeAi\PlatformFactory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
require_once dirname(__DIR__).'/bootstrap.php';
$platform = PlatformFactory::create(env('AMAZEEAI_LLM_API_URL'), env('AMAZEEAI_LLM_KEY'), http_client());
$messages = new MessageBag(Message::ofUser('List the first 50 prime number?'));
$result = $platform->invoke('claude-3-5-haiku', $messages, [
'stream' => true,
]);
foreach ($result->asStream() as $word) {
echo $word;
}
echo \PHP_EOL;

View File

@@ -41,6 +41,7 @@
},
"ai-ai-ml-api-platform": "src/platform/src/Bridge/AiMlApi",
"ai-albert-platform": "src/platform/src/Bridge/Albert",
"ai-amazeeai-platform": "src/platform/src/Bridge/AmazeeAi",
"ai-anthropic-platform": "src/platform/src/Bridge/Anthropic",
"ai-azure-platform": "src/platform/src/Bridge/Azure",
"ai-bedrock-platform": "src/platform/src/Bridge/Bedrock",

View File

@@ -28,6 +28,7 @@ return static function (DefinitionConfigurator $configurator): void {
->arrayNode('platform')
->children()
->append($import('platform/albert'))
->append($import('platform/amazeeai'))
->append($import('platform/anthropic'))
->append($import('platform/azure'))
->append($import('platform/bedrock'))

View File

@@ -0,0 +1,24 @@
<?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\Component\Config\Definition\Configurator;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
return (new ArrayNodeDefinition('amazeeai'))
->children()
->stringNode('base_url')->isRequired()->end()
->stringNode('api_key')->isRequired()->end()
->stringNode('http_client')
->defaultValue('http_client')
->info('Service ID of the HTTP client to use')
->end()
->end();

View File

@@ -26,6 +26,7 @@ use Symfony\AI\Chat\Command\SetupStoreCommand as SetupMessageStoreCommand;
use Symfony\AI\Chat\MessageNormalizer;
use Symfony\AI\Platform\Bridge\AiMlApi\ModelCatalog as AiMlApiModelCatalog;
use Symfony\AI\Platform\Bridge\Albert\ModelCatalog as AlbertModelCatalog;
use Symfony\AI\Platform\Bridge\AmazeeAi\ModelApiCatalog as AmazeeAiModelCatalog;
use Symfony\AI\Platform\Bridge\Anthropic\Contract\AnthropicContract;
use Symfony\AI\Platform\Bridge\Anthropic\ModelCatalog as AnthropicModelCatalog;
use Symfony\AI\Platform\Bridge\Azure\OpenAi\ModelCatalog as AzureOpenAiModelCatalog;
@@ -96,6 +97,7 @@ return static function (ContainerConfigurator $container): void {
// model catalog
->set('ai.platform.model_catalog.aimlapi', AiMlApiModelCatalog::class)
->set('ai.platform.model_catalog.albert', AlbertModelCatalog::class)
->set('ai.platform.model_catalog.amazeeai', AmazeeAiModelCatalog::class)
->set('ai.platform.model_catalog.anthropic', AnthropicModelCatalog::class)
->set('ai.platform.model_catalog.azure.openai', AzureOpenAiModelCatalog::class)
->set('ai.platform.model_catalog.bedrock', BedrockModelCatalog::class)

View File

@@ -21,6 +21,7 @@ To use a specific AI platform, install the corresponding bridge package:
|---------------------|-------------------------------------------|
| AI.ML API | `symfony/ai-ai-ml-api-platform` |
| Albert | `symfony/ai-albert-platform` |
| amazee.ai | `symfony/ai-amazee-ai-platform` |
| Anthropic | `symfony/ai-anthropic-platform` |
| Azure OpenAI | `symfony/ai-azure-platform` |
| AWS Bedrock | `symfony/ai-bedrock-platform` |

View File

@@ -7,6 +7,7 @@
"ai",
"aimlapi",
"albert",
"amazeeai",
"anthropic",
"azure",
"bedrock",

View File

@@ -0,0 +1,3 @@
/Tests export-ignore
/phpunit.xml.dist export-ignore
/.git* export-ignore

View File

@@ -0,0 +1,8 @@
Please do not submit any Pull Requests here. They will be closed.
---
Please submit your PR here instead:
https://github.com/symfony/ai
This repository is what we call a "subtree split": a read-only subset of that main repository.
We're looking forward to your PR there!

View File

@@ -0,0 +1,20 @@
name: Close Pull Request
on:
pull_request_target:
types: [opened]
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: superbrothers/close-pull-request@v3
with:
comment: |
Thanks for your Pull Request! We love contributions.
However, you should instead open your PR on the main repository:
https://github.com/symfony/ai
This repository is what we call a "subtree split": a read-only subset of that main repository.
We're looking forward to your PR there!

View File

@@ -0,0 +1,4 @@
vendor/
composer.lock
phpunit.xml
.phpunit.result.cache

View File

@@ -0,0 +1,7 @@
CHANGELOG
=========
0.4
---
* Add the bridge

View File

@@ -0,0 +1,202 @@
<?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\AmazeeAi;
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
use Symfony\AI\Platform\Exception\AuthenticationException;
use Symfony\AI\Platform\Exception\BadRequestException;
use Symfony\AI\Platform\Exception\ContentFilterException;
use Symfony\AI\Platform\Exception\RateLimitExceededException;
use Symfony\AI\Platform\Exception\RuntimeException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\Result\ChoiceResult;
use Symfony\AI\Platform\Result\RawHttpResult;
use Symfony\AI\Platform\Result\RawResultInterface;
use Symfony\AI\Platform\Result\ResultInterface;
use Symfony\AI\Platform\Result\StreamResult;
use Symfony\AI\Platform\Result\TextResult;
use Symfony\AI\Platform\Result\ToolCall;
use Symfony\AI\Platform\Result\ToolCallResult;
use Symfony\AI\Platform\ResultConverterInterface;
use Symfony\AI\Platform\TokenUsage\TokenUsageExtractorInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Completions ResultConverter for amazee.ai's LiteLLM proxy.
*
* LiteLLM may return finish_reason "tool_calls" for structured output
* responses but place the content in message.content instead of
* message.tool_calls. This converter handles that quirk by checking
* for tool_calls first and falling back to message.content as TextResult.
*/
class CompletionsResultConverter implements ResultConverterInterface
{
public function supports(Model $model): bool
{
return $model instanceof CompletionsModel;
}
public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface
{
$response = $result->getObject();
\assert($response instanceof ResponseInterface);
if (401 === $response->getStatusCode()) {
$errorMessage = json_decode($response->getContent(false), true)['error']['message'];
throw new AuthenticationException($errorMessage);
}
if (400 === $response->getStatusCode()) {
$errorMessage = json_decode($response->getContent(false), true)['error']['message'] ?? 'Bad Request';
throw new BadRequestException($errorMessage);
}
if (429 === $response->getStatusCode()) {
throw new RateLimitExceededException();
}
if ($options['stream'] ?? false) {
return new StreamResult($this->convertStream($result));
}
$data = $result->getData();
if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) {
throw new ContentFilterException($data['error']['message']);
}
if (isset($data['error'])) {
throw new RuntimeException(\sprintf('Error "%s"-%s (%s): "%s".', $data['error']['code'] ?? '-', $data['error']['type'] ?? '-', $data['error']['param'] ?? '-', $data['error']['message'] ?? '-'));
}
if (!isset($data['choices'])) {
throw new RuntimeException('Response does not contain choices.');
}
$choices = array_map($this->convertChoice(...), $data['choices']);
return 1 === \count($choices) ? $choices[0] : new ChoiceResult($choices);
}
public function getTokenUsageExtractor(): ?TokenUsageExtractorInterface
{
return null;
}
private function convertStream(RawResultInterface|RawHttpResult $result): \Generator
{
$toolCalls = [];
foreach ($result->getDataStream() as $data) {
if ($this->streamIsToolCall($data)) {
$toolCalls = $this->convertStreamToToolCalls($toolCalls, $data);
}
if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) {
yield new ToolCallResult(...array_map($this->convertToolCall(...), $toolCalls));
}
if (!isset($data['choices'][0]['delta']['content'])) {
continue;
}
yield $data['choices'][0]['delta']['content'];
}
}
/**
* @param array<string, mixed> $toolCalls
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
private function convertStreamToToolCalls(array $toolCalls, array $data): array
{
if (!isset($data['choices'][0]['delta']['tool_calls'])) {
return $toolCalls;
}
foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) {
if (isset($toolCall['id'])) {
$toolCalls[$i] = [
'id' => $toolCall['id'],
'function' => $toolCall['function'],
];
continue;
}
$toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments'];
}
return $toolCalls;
}
/**
* @param array<string, mixed> $data
*/
private function streamIsToolCall(array $data): bool
{
return isset($data['choices'][0]['delta']['tool_calls']);
}
/**
* @param array<string, mixed> $data
*/
private function isToolCallsStreamFinished(array $data): bool
{
return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason'];
}
/**
* Converts a choice, handling LiteLLM's quirk where finish_reason is
* "tool_calls" but the actual content is in message.content (structured output)
* instead of message.tool_calls.
*
* @param array<string, mixed> $choice
*/
private function convertChoice(array $choice): ToolCallResult|TextResult
{
if ('tool_calls' === $choice['finish_reason']) {
if (isset($choice['message']['tool_calls'])) {
return new ToolCallResult(...array_map([$this, 'convertToolCall'], $choice['message']['tool_calls']));
}
// LiteLLM structured output: finish_reason is "tool_calls" but
// content is in message.content instead of message.tool_calls
if (isset($choice['message']['content'])) {
return new TextResult($choice['message']['content']);
}
}
if (\in_array($choice['finish_reason'], ['stop', 'length'], true)) {
return new TextResult($choice['message']['content']);
}
throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason']));
}
/**
* @param array{
* id: string,
* type: 'function',
* function: array{
* name: string,
* arguments: string
* }
* } $toolCall
*/
private function convertToolCall(array $toolCall): ToolCall
{
$arguments = json_decode($toolCall['function']['arguments'], true, flags: \JSON_THROW_ON_ERROR);
return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments);
}
}

View File

@@ -0,0 +1,19 @@
Copyright (c) 2026-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,142 @@
<?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\AmazeeAi;
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
use Symfony\AI\Platform\Bridge\Generic\EmbeddingsModel;
use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelCatalog\AbstractModelCatalog;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Model catalog that discovers available models from the amazee.ai
* LiteLLM /model/info endpoint.
*
* Maps each model to CompletionsModel or EmbeddingsModel based on the
* mode field, so the Generic platform's ModelClients can route requests
* correctly.
*/
final class ModelApiCatalog extends AbstractModelCatalog
{
private bool $modelsAreLoaded = false;
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $baseUrl,
#[\SensitiveParameter] private readonly ?string $apiKey = null,
) {
$this->models = [];
}
public function getModel(string $modelName): Model
{
$this->preloadRemoteModels();
return parent::getModel($modelName);
}
/**
* @return array<string, array{class: class-string, capabilities: list<Capability>}>
*/
public function getModels(): array
{
$this->preloadRemoteModels();
return parent::getModels();
}
private function preloadRemoteModels(): void
{
if ($this->modelsAreLoaded) {
return;
}
$this->modelsAreLoaded = true;
$this->models = [...$this->models, ...$this->fetchRemoteModels()];
}
/**
* @return iterable<string, array{class: class-string<Model>, capabilities: list<Capability>}>
*/
private function fetchRemoteModels(): iterable
{
$response = $this->httpClient->request('GET', $this->baseUrl.'/model/info', [
'headers' => array_filter([
'Authorization' => $this->apiKey ? 'Bearer '.$this->apiKey : null,
]),
]);
foreach ($response->toArray()['data'] ?? [] as $modelInfo) {
$name = $modelInfo['model_name'] ?? null;
if (null === $name) {
continue;
}
$info = $modelInfo['model_info'] ?? [];
$mode = $info['mode'] ?? null;
if ('embedding' === $mode) {
yield $name => [
'class' => EmbeddingsModel::class,
'capabilities' => $this->buildEmbeddingCapabilities($info),
];
} else {
yield $name => [
'class' => CompletionsModel::class,
'capabilities' => $this->buildCompletionsCapabilities($info),
];
}
}
}
/**
* @param array<string, mixed> $info
*
* @return list<Capability>
*/
private function buildEmbeddingCapabilities(array $info): array
{
$capabilities = [Capability::EMBEDDINGS, Capability::INPUT_TEXT];
if ($info['supports_multiple_inputs'] ?? true) {
$capabilities[] = Capability::INPUT_MULTIPLE;
}
return $capabilities;
}
/**
* @param array<string, mixed> $info
*
* @return list<Capability>
*/
private function buildCompletionsCapabilities(array $info): array
{
$capabilities = [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING];
if ($info['supports_image_input'] ?? false) {
$capabilities[] = Capability::INPUT_IMAGE;
}
if ($info['supports_audio_input'] ?? false) {
$capabilities[] = Capability::INPUT_AUDIO;
}
if ($info['supports_tool_calling'] ?? $info['supports_function_calling'] ?? false) {
$capabilities[] = Capability::TOOL_CALLING;
}
if ($info['supports_response_schema'] ?? false) {
$capabilities[] = Capability::OUTPUT_STRUCTURED;
}
return $capabilities;
}
}

View File

@@ -0,0 +1,50 @@
<?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\AmazeeAi;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\AI\Platform\Bridge\Generic\Completions\ModelClient;
use Symfony\AI\Platform\Bridge\Generic\Embeddings;
use Symfony\AI\Platform\Contract;
use Symfony\AI\Platform\ModelCatalog\FallbackModelCatalog;
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
use Symfony\AI\Platform\Platform;
use Symfony\Component\HttpClient\EventSourceHttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class PlatformFactory
{
public static function create(
string $baseUrl,
#[\SensitiveParameter] ?string $apiKey = null,
?HttpClientInterface $httpClient = null,
ModelCatalogInterface $modelCatalog = new FallbackModelCatalog(),
?Contract $contract = null,
?EventDispatcherInterface $eventDispatcher = null,
): Platform {
$httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
return new Platform(
[
new ModelClient($httpClient, $baseUrl, $apiKey),
new Embeddings\ModelClient($httpClient, $baseUrl, $apiKey),
],
[
new CompletionsResultConverter(),
new Embeddings\ResultConverter(),
],
$modelCatalog,
$contract,
$eventDispatcher,
);
}
}

View File

@@ -0,0 +1,12 @@
amazee.ai Platform
===================
amazee.ai 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,146 @@
<?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\AmazeeAi\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\AmazeeAi\CompletionsResultConverter;
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
use Symfony\AI\Platform\Bridge\Generic\EmbeddingsModel;
use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\Exception\RuntimeException;
use Symfony\AI\Platform\Result\RawHttpResult;
use Symfony\AI\Platform\Result\TextResult;
use Symfony\AI\Platform\Result\ToolCallResult;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
final class CompletionsResultConverterTest extends TestCase
{
public function testSupportsCompletionsModel()
{
$converter = new CompletionsResultConverter();
$this->assertTrue($converter->supports(new CompletionsModel('test', [Capability::INPUT_MESSAGES])));
}
public function testDoesNotSupportEmbeddingsModel()
{
$converter = new CompletionsResultConverter();
$this->assertFalse($converter->supports(new EmbeddingsModel('test', [Capability::EMBEDDINGS])));
}
public function testConvertStopFinishReason()
{
$converter = new CompletionsResultConverter();
$result = new RawHttpResult($this->createResponse([
'choices' => [
[
'finish_reason' => 'stop',
'message' => ['content' => 'Hello world'],
],
],
]));
$converted = $converter->convert($result);
$this->assertInstanceOf(TextResult::class, $converted);
$this->assertSame('Hello world', $converted->getContent());
}
public function testConvertToolCallsFinishReasonWithToolCalls()
{
$converter = new CompletionsResultConverter();
$result = new RawHttpResult($this->createResponse([
'choices' => [
[
'finish_reason' => 'tool_calls',
'message' => [
'tool_calls' => [
[
'id' => 'call_123',
'type' => 'function',
'function' => [
'name' => 'get_weather',
'arguments' => '{"city":"Paris"}',
],
],
],
],
],
],
]));
$converted = $converter->convert($result);
$this->assertInstanceOf(ToolCallResult::class, $converted);
}
public function testConvertToolCallsFinishReasonWithContentFallback()
{
$converter = new CompletionsResultConverter();
$result = new RawHttpResult($this->createResponse([
'choices' => [
[
'finish_reason' => 'tool_calls',
'message' => [
'content' => '{"recipe":"Pasta Carbonara","ingredients":["pasta","eggs","bacon"]}',
],
],
],
]));
$converted = $converter->convert($result);
$this->assertInstanceOf(TextResult::class, $converted);
$this->assertSame(
'{"recipe":"Pasta Carbonara","ingredients":["pasta","eggs","bacon"]}',
$converted->getContent(),
);
}
public function testConvertToolCallsFinishReasonWithoutContentThrows()
{
$converter = new CompletionsResultConverter();
$result = new RawHttpResult($this->createResponse([
'choices' => [
[
'finish_reason' => 'tool_calls',
'message' => [],
],
],
]));
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Unsupported finish reason "tool_calls"');
$converter->convert($result);
}
public function testGetTokenUsageExtractorReturnsNull()
{
$converter = new CompletionsResultConverter();
$this->assertNull($converter->getTokenUsageExtractor());
}
/**
* @param array<string, mixed> $data
*/
private function createResponse(array $data): \Symfony\Contracts\HttpClient\ResponseInterface
{
$httpClient = new MockHttpClient([
new JsonMockResponse($data),
]);
return $httpClient->request('POST', 'https://litellm.example.com/v1/chat/completions');
}
}

View File

@@ -0,0 +1,179 @@
<?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\AmazeeAi\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\AmazeeAi\ModelApiCatalog;
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
use Symfony\AI\Platform\Bridge\Generic\EmbeddingsModel;
use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\Exception\ModelNotFoundException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\JsonMockResponse;
final class ModelApiCatalogTest extends TestCase
{
public function testLazyLoadModels()
{
$httpClient = new MockHttpClient([
new JsonMockResponse($this->getModelInfoResponse()),
]);
$catalog = new ModelApiCatalog($httpClient, 'https://litellm.example.com', 'test-key');
$models = $catalog->getModels();
$this->assertArrayHasKey('claude-3-5-sonnet', $models);
$this->assertArrayHasKey('titan-embed-text-v2:0', $models);
}
public function testCompletionsModel()
{
$httpClient = new MockHttpClient([
new JsonMockResponse($this->getModelInfoResponse()),
]);
$catalog = new ModelApiCatalog($httpClient, 'https://litellm.example.com', 'test-key');
$model = $catalog->getModel('claude-3-5-sonnet');
$this->assertInstanceOf(CompletionsModel::class, $model);
}
public function testEmbeddingsModel()
{
$httpClient = new MockHttpClient([
new JsonMockResponse($this->getModelInfoResponse()),
]);
$catalog = new ModelApiCatalog($httpClient, 'https://litellm.example.com', 'test-key');
$model = $catalog->getModel('titan-embed-text-v2:0');
$this->assertInstanceOf(EmbeddingsModel::class, $model);
}
public function testCompletionsCapabilities()
{
$httpClient = new MockHttpClient([
new JsonMockResponse($this->getModelInfoResponse()),
]);
$catalog = new ModelApiCatalog($httpClient, 'https://litellm.example.com', 'test-key');
$models = $catalog->getModels();
$capabilities = $models['claude-3-5-sonnet']['capabilities'];
$this->assertContains(Capability::INPUT_MESSAGES, $capabilities);
$this->assertContains(Capability::OUTPUT_TEXT, $capabilities);
$this->assertContains(Capability::OUTPUT_STREAMING, $capabilities);
$this->assertContains(Capability::INPUT_IMAGE, $capabilities);
$this->assertContains(Capability::TOOL_CALLING, $capabilities);
$this->assertContains(Capability::OUTPUT_STRUCTURED, $capabilities);
}
public function testEmbeddingCapabilities()
{
$httpClient = new MockHttpClient([
new JsonMockResponse($this->getModelInfoResponse()),
]);
$catalog = new ModelApiCatalog($httpClient, 'https://litellm.example.com', 'test-key');
$models = $catalog->getModels();
$capabilities = $models['titan-embed-text-v2:0']['capabilities'];
$this->assertContains(Capability::EMBEDDINGS, $capabilities);
$this->assertContains(Capability::INPUT_TEXT, $capabilities);
$this->assertContains(Capability::INPUT_MULTIPLE, $capabilities);
}
public function testModelNotFound()
{
$httpClient = new MockHttpClient([
new JsonMockResponse($this->getModelInfoResponse()),
]);
$catalog = new ModelApiCatalog($httpClient, 'https://litellm.example.com', 'test-key');
$this->expectException(ModelNotFoundException::class);
$catalog->getModel('non-existent-model');
}
public function testModelsAreLoadedOnlyOnce()
{
$callCount = 0;
$httpClient = new MockHttpClient(function () use (&$callCount) {
++$callCount;
return new JsonMockResponse($this->getModelInfoResponse());
});
$catalog = new ModelApiCatalog($httpClient, 'https://litellm.example.com', 'test-key');
$catalog->getModels();
$catalog->getModels();
$catalog->getModel('claude-3-5-sonnet');
$this->assertSame(1, $callCount);
}
public function testWithoutApiKey()
{
$httpClient = new MockHttpClient([
new JsonMockResponse($this->getModelInfoResponse()),
]);
$catalog = new ModelApiCatalog($httpClient, 'https://litellm.example.com');
$models = $catalog->getModels();
$this->assertNotEmpty($models);
}
/**
* @return array<string, mixed>
*/
private function getModelInfoResponse(): array
{
return [
'data' => [
[
'model_name' => 'claude-3-5-sonnet',
'model_info' => [
'mode' => 'chat',
'supports_image_input' => true,
'supports_audio_input' => false,
'supports_tool_calling' => true,
'supports_response_schema' => true,
],
],
[
'model_name' => 'claude-3-5-haiku',
'model_info' => [
'mode' => 'chat',
'supports_image_input' => false,
'supports_tool_calling' => true,
'supports_response_schema' => false,
],
],
[
'model_name' => 'titan-embed-text-v2:0',
'model_info' => [
'mode' => 'embedding',
'supports_multiple_inputs' => true,
],
],
],
];
}
}

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\AmazeeAi\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\AmazeeAi\PlatformFactory;
use Symfony\AI\Platform\Platform;
use Symfony\Component\HttpClient\EventSourceHttpClient;
use Symfony\Component\HttpClient\MockHttpClient;
final class PlatformFactoryTest extends TestCase
{
public function testCreateWithDefaults()
{
$platform = PlatformFactory::create(
'https://litellm.example.com',
'test-api-key',
);
$this->assertInstanceOf(Platform::class, $platform);
}
public function testCreateWithCustomHttpClient()
{
$httpClient = new MockHttpClient();
$platform = PlatformFactory::create(
'https://litellm.example.com',
'test-api-key',
$httpClient,
);
$this->assertInstanceOf(Platform::class, $platform);
}
public function testCreateWithEventSourceHttpClient()
{
$httpClient = new EventSourceHttpClient(new MockHttpClient());
$platform = PlatformFactory::create(
'https://litellm.example.com',
'test-api-key',
$httpClient,
);
$this->assertInstanceOf(Platform::class, $platform);
}
}

View File

@@ -0,0 +1,56 @@
{
"name": "symfony/ai-amazee-ai-platform",
"description": "amazee.ai LiteLLM platform bridge for Symfony AI",
"license": "MIT",
"type": "symfony-ai-platform",
"keywords": [
"ai",
"bridge",
"amazeeai",
"litellm",
"platform"
],
"authors": [
{
"name": "Christophe Jossart",
"email": "christophe@colorfield.dev"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"require": {
"php": ">=8.2",
"symfony/ai-generic-platform": "^0.4",
"symfony/ai-platform": "^0.4",
"symfony/http-client": "^7.3|^8.0"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^11.5.53"
},
"minimum-stability": "dev",
"autoload": {
"psr-4": {
"Symfony\\AI\\Platform\\Bridge\\AmazeeAi\\": ""
}
},
"autoload-dev": {
"psr-4": {
"Symfony\\AI\\PHPStan\\": "../../../../../.phpstan/",
"Symfony\\AI\\Platform\\Bridge\\AmazeeAi\\Tests\\": "Tests/"
}
},
"config": {
"sort-packages": true
},
"extra": {
"thanks": {
"name": "symfony/ai",
"url": "https://github.com/symfony/ai"
}
}
}

View File

@@ -0,0 +1,29 @@
includes:
- vendor/phpstan/phpstan-phpunit/extension.neon
- ../../../../../.phpstan/extension.neon
parameters:
level: 6
paths:
- .
- Tests/
excludePaths:
- vendor/
treatPhpDocTypesAsCertain: false
ignoreErrors:
-
message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#"
reportUnmatched: false
-
message: '#^Call to( static)? method PHPUnit\\Framework\\Assert::.* will always evaluate to true\.$#'
reportUnmatched: false
-
identifier: 'symfonyAi.forbidNativeException'
path: Tests/*
reportUnmatched: false
services:
- # Conditionally enabled by bleeding edge in phpstan/phpstan-phpunit 2.x
class: PHPStan\Type\PHPUnit\DataProviderReturnTypeIgnoreExtension
tags:
- phpstan.ignoreErrorExtension

View File

@@ -0,0 +1,33 @@
<?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"
executionOrder="random"
>
<php>
<ini name="error_reporting" value="-1" />
</php>
<testsuites>
<testsuite name="Symfony AI amazee.ai 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>