mirror of
https://github.com/symfony/ai-ollama-platform.git
synced 2026-03-23 23:22:07 +01:00
[Platform] Split bridges into dedicated packages
This commit is contained in:
12
README.md
Normal file
12
README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
Ollama Platform
|
||||
===============
|
||||
|
||||
Ollama 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)
|
||||
162
Tests/Contract/AssistantMessageNormalizerTest.php
Normal file
162
Tests/Contract/AssistantMessageNormalizerTest.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?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\Ollama\Tests\Contract;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Platform\Bridge\Ollama\Contract\AssistantMessageNormalizer;
|
||||
use Symfony\AI\Platform\Bridge\Ollama\Ollama;
|
||||
use Symfony\AI\Platform\Contract;
|
||||
use Symfony\AI\Platform\Message\AssistantMessage;
|
||||
use Symfony\AI\Platform\Message\Role;
|
||||
use Symfony\AI\Platform\Model;
|
||||
use Symfony\AI\Platform\Result\ToolCall;
|
||||
|
||||
final class AssistantMessageNormalizerTest extends TestCase
|
||||
{
|
||||
private AssistantMessageNormalizer $normalizer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->normalizer = new AssistantMessageNormalizer();
|
||||
}
|
||||
|
||||
public function testSupportsNormalization()
|
||||
{
|
||||
$this->assertTrue($this->normalizer->supportsNormalization(new AssistantMessage('Hello'), context: [
|
||||
Contract::CONTEXT_MODEL => new Ollama('llama3.2'),
|
||||
]));
|
||||
$this->assertFalse($this->normalizer->supportsNormalization(new AssistantMessage('Hello'), context: [
|
||||
Contract::CONTEXT_MODEL => new Model('any-model'),
|
||||
]));
|
||||
$this->assertFalse($this->normalizer->supportsNormalization('not an assistant message'));
|
||||
}
|
||||
|
||||
public function testGetSupportedTypes()
|
||||
{
|
||||
$this->assertSame([AssistantMessage::class => true], $this->normalizer->getSupportedTypes(null));
|
||||
}
|
||||
|
||||
#[DataProvider('normalizeDataProvider')]
|
||||
public function testNormalize(AssistantMessage $message, array $expectedOutput)
|
||||
{
|
||||
$normalized = $this->normalizer->normalize($message);
|
||||
|
||||
$this->assertEquals($expectedOutput, $normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{AssistantMessage, array{role: Role::Assistant, tool_calls: list<array{type: string, function: array{name: string, arguments: mixed}}>}}>
|
||||
*/
|
||||
public static function normalizeDataProvider(): iterable
|
||||
{
|
||||
yield 'assistant message without tool calls' => [
|
||||
new AssistantMessage('Hello'),
|
||||
[
|
||||
'role' => Role::Assistant,
|
||||
'content' => 'Hello',
|
||||
'tool_calls' => [],
|
||||
],
|
||||
];
|
||||
|
||||
yield 'assistant message with tool calls' => [
|
||||
new AssistantMessage(toolCalls: [new ToolCall('id1', 'function1', ['param' => 'value'])]),
|
||||
[
|
||||
'role' => Role::Assistant,
|
||||
'content' => '',
|
||||
'tool_calls' => [
|
||||
[
|
||||
'type' => 'function',
|
||||
'function' => [
|
||||
'name' => 'function1',
|
||||
'arguments' => ['param' => 'value'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
yield 'assistant message with empty arguments' => [
|
||||
new AssistantMessage(toolCalls: [new ToolCall('id1', 'function1', [])]),
|
||||
[
|
||||
'role' => Role::Assistant,
|
||||
'content' => '',
|
||||
'tool_calls' => [
|
||||
[
|
||||
'type' => 'function',
|
||||
'function' => [
|
||||
'name' => 'function1',
|
||||
'arguments' => new \stdClass(),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
yield 'assistant message with multiple tool calls' => [
|
||||
new AssistantMessage(toolCalls: [
|
||||
new ToolCall('id1', 'function1', ['param1' => 'value1']),
|
||||
new ToolCall('id2', 'function2', ['param2' => 'value2']),
|
||||
]),
|
||||
[
|
||||
'role' => Role::Assistant,
|
||||
'content' => '',
|
||||
'tool_calls' => [
|
||||
[
|
||||
'type' => 'function',
|
||||
'function' => [
|
||||
'name' => 'function1',
|
||||
'arguments' => ['param1' => 'value1'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'function',
|
||||
'function' => [
|
||||
'name' => 'function2',
|
||||
'arguments' => ['param2' => 'value2'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
yield 'assistant message with tool calls and content' => [
|
||||
new AssistantMessage(
|
||||
content: 'Hello',
|
||||
toolCalls: [
|
||||
new ToolCall('id1', 'function1', ['param1' => 'value1']),
|
||||
new ToolCall('id2', 'function2', ['param2' => 'value2']),
|
||||
]
|
||||
),
|
||||
[
|
||||
'role' => Role::Assistant,
|
||||
'content' => 'Hello',
|
||||
'tool_calls' => [
|
||||
[
|
||||
'type' => 'function',
|
||||
'function' => [
|
||||
'name' => 'function1',
|
||||
'arguments' => ['param1' => 'value1'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'function',
|
||||
'function' => [
|
||||
'name' => 'function2',
|
||||
'arguments' => ['param2' => 'value2'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
57
Tests/ModelCatalogTest.php
Normal file
57
Tests/ModelCatalogTest.php
Normal 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\Ollama\Tests;
|
||||
|
||||
use Symfony\AI\Platform\Bridge\Ollama\ModelCatalog;
|
||||
use Symfony\AI\Platform\Bridge\Ollama\Ollama;
|
||||
use Symfony\AI\Platform\Capability;
|
||||
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
|
||||
use Symfony\AI\Platform\Test\ModelCatalogTestCase;
|
||||
|
||||
/**
|
||||
* @author Oskar Stark <oskarstark@googlemail.com>
|
||||
*/
|
||||
final class ModelCatalogTest extends ModelCatalogTestCase
|
||||
{
|
||||
public static function modelsProvider(): iterable
|
||||
{
|
||||
yield 'deepseek-r1' => ['deepseek-r1', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::TOOL_CALLING]];
|
||||
yield 'gpt-oss' => ['gpt-oss', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::TOOL_CALLING]];
|
||||
yield 'llama3.1' => ['llama3.1', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::TOOL_CALLING]];
|
||||
yield 'llama3.2' => ['llama3.2', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::TOOL_CALLING]];
|
||||
yield 'llama3' => ['llama3', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::TOOL_CALLING]];
|
||||
yield 'mistral' => ['mistral', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::TOOL_CALLING]];
|
||||
yield 'qwen3' => ['qwen3', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::TOOL_CALLING]];
|
||||
yield 'qwen3:32b' => ['qwen3:32b', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::TOOL_CALLING]];
|
||||
yield 'qwen' => ['qwen', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::TOOL_CALLING]];
|
||||
yield 'qwen2' => ['qwen2', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::TOOL_CALLING]];
|
||||
yield 'qwen2.5' => ['qwen2.5', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::TOOL_CALLING]];
|
||||
yield 'qwen2.5-coder' => ['qwen2.5-coder', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::TOOL_CALLING]];
|
||||
yield 'gemma3n' => ['gemma3n', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED]];
|
||||
yield 'gemma3' => ['gemma3', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED]];
|
||||
yield 'qwen2.5vl' => ['qwen2.5vl', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED]];
|
||||
yield 'llava' => ['llava', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED]];
|
||||
yield 'phi3' => ['phi3', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED]];
|
||||
yield 'gemma2' => ['gemma2', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED]];
|
||||
yield 'gemma' => ['gemma', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED]];
|
||||
yield 'llama2' => ['llama2', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED]];
|
||||
yield 'nomic-embed-text' => ['nomic-embed-text', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::INPUT_MULTIPLE]];
|
||||
yield 'bge-m3' => ['bge-m3', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::INPUT_MULTIPLE]];
|
||||
yield 'all-minilm' => ['all-minilm', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::INPUT_MULTIPLE]];
|
||||
yield 'all-minilm:33m' => ['all-minilm:33m', Ollama::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STRUCTURED, Capability::INPUT_MULTIPLE]];
|
||||
}
|
||||
|
||||
protected function createModelCatalog(): ModelCatalogInterface
|
||||
{
|
||||
return new ModelCatalog();
|
||||
}
|
||||
}
|
||||
73
Tests/OllamaApiCatalogTest.php
Normal file
73
Tests/OllamaApiCatalogTest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?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\Ollama\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Platform\Bridge\Ollama\Ollama;
|
||||
use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
|
||||
use Symfony\AI\Platform\Capability;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\JsonMockResponse;
|
||||
|
||||
final class OllamaApiCatalogTest extends TestCase
|
||||
{
|
||||
public function testModelCatalogCanReturnModelFromApi()
|
||||
{
|
||||
$httpClient = new MockHttpClient([
|
||||
new JsonMockResponse([
|
||||
'capabilities' => ['completion'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$modelCatalog = new OllamaApiCatalog('http://127.0.0.1:11434', $httpClient);
|
||||
|
||||
$model = $modelCatalog->getModel('foo');
|
||||
|
||||
$this->assertSame('foo', $model->getName());
|
||||
$this->assertSame([
|
||||
Capability::INPUT_MESSAGES,
|
||||
], $model->getCapabilities());
|
||||
$this->assertSame(1, $httpClient->getRequestsCount());
|
||||
}
|
||||
|
||||
public function testModelCatalogCanReturnModelsFromApi()
|
||||
{
|
||||
$httpClient = new MockHttpClient([
|
||||
new JsonMockResponse([
|
||||
'models' => [
|
||||
[
|
||||
'name' => 'gemma3',
|
||||
'details' => [],
|
||||
],
|
||||
],
|
||||
]),
|
||||
new JsonMockResponse([
|
||||
'capabilities' => ['completion'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$modelCatalog = new OllamaApiCatalog('http://127.0.0.1:11434', $httpClient);
|
||||
|
||||
$models = $modelCatalog->getModels();
|
||||
|
||||
$this->assertCount(1, $models);
|
||||
$this->assertArrayHasKey('gemma3', $models);
|
||||
|
||||
$model = $models['gemma3'];
|
||||
$this->assertSame(Ollama::class, $model['class']);
|
||||
$this->assertCount(1, $model['capabilities']);
|
||||
$this->assertSame([
|
||||
Capability::INPUT_MESSAGES,
|
||||
], $model['capabilities']);
|
||||
$this->assertSame(2, $httpClient->getRequestsCount());
|
||||
}
|
||||
}
|
||||
175
Tests/OllamaClientTest.php
Normal file
175
Tests/OllamaClientTest.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?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\Ollama\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Platform\Bridge\Ollama\Ollama;
|
||||
use Symfony\AI\Platform\Bridge\Ollama\OllamaClient;
|
||||
use Symfony\AI\Platform\Bridge\Ollama\OllamaResultConverter;
|
||||
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory;
|
||||
use Symfony\AI\Platform\Capability;
|
||||
use Symfony\AI\Platform\Model;
|
||||
use Symfony\AI\Platform\Result\RawHttpResult;
|
||||
use Symfony\AI\Platform\Result\StreamResult;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\JsonMockResponse;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
final class OllamaClientTest extends TestCase
|
||||
{
|
||||
public function testSupportsModel()
|
||||
{
|
||||
$client = new OllamaClient(new MockHttpClient(), 'http://127.0.0.1:1234');
|
||||
|
||||
$this->assertTrue($client->supports(new Ollama('llama3.2')));
|
||||
$this->assertFalse($client->supports(new Model('any-model')));
|
||||
}
|
||||
|
||||
public function testOutputStructureIsSupported()
|
||||
{
|
||||
$httpClient = new MockHttpClient([
|
||||
new JsonMockResponse([
|
||||
'model' => 'foo',
|
||||
'response' => [
|
||||
'age' => 22,
|
||||
'available' => true,
|
||||
],
|
||||
'done' => true,
|
||||
]),
|
||||
], 'http://127.0.0.1:1234');
|
||||
|
||||
$client = new OllamaClient($httpClient, 'http://127.0.0.1:1234');
|
||||
$response = $client->request(new Ollama('llama3.2', [
|
||||
Capability::INPUT_MESSAGES,
|
||||
Capability::TOOL_CALLING,
|
||||
]), [
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => 'Ollama is 22 years old and is busy saving the world. Respond using JSON',
|
||||
],
|
||||
],
|
||||
'model' => 'llama3.2',
|
||||
], [
|
||||
'response_format' => [
|
||||
'type' => 'json_schema',
|
||||
'json_schema' => [
|
||||
'name' => 'clock',
|
||||
'strict' => true,
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'age' => ['type' => 'integer'],
|
||||
'available' => ['type' => 'boolean'],
|
||||
],
|
||||
'required' => ['age', 'available'],
|
||||
'additionalProperties' => false,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $httpClient->getRequestsCount());
|
||||
$this->assertSame([
|
||||
'model' => 'foo',
|
||||
'response' => [
|
||||
'age' => 22,
|
||||
'available' => true,
|
||||
],
|
||||
'done' => true,
|
||||
], $response->getData());
|
||||
}
|
||||
|
||||
public function testStreamingIsSupported()
|
||||
{
|
||||
$httpClient = new MockHttpClient([
|
||||
new MockResponse('data: '.json_encode([
|
||||
'model' => 'llama3.2',
|
||||
'created_at' => '2025-08-23T10:00:00Z',
|
||||
'message' => ['role' => 'assistant', 'content' => 'Hello world'],
|
||||
'done' => true,
|
||||
'prompt_eval_count' => 10,
|
||||
'eval_count' => 10,
|
||||
])."\n\n", [
|
||||
'response_headers' => [
|
||||
'content-type' => 'text/event-stream',
|
||||
],
|
||||
]),
|
||||
], 'http://127.0.0.1:1234');
|
||||
|
||||
$platform = PlatformFactory::create('http://127.0.0.1:1234', $httpClient);
|
||||
$response = $platform->invoke('llama3.2', [
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => 'Say hello world',
|
||||
],
|
||||
],
|
||||
'model' => 'llama3.2',
|
||||
], [
|
||||
'stream' => true,
|
||||
]);
|
||||
|
||||
$result = $response->getResult();
|
||||
|
||||
$this->assertInstanceOf(StreamResult::class, $result);
|
||||
$this->assertInstanceOf(\Generator::class, $result->getContent());
|
||||
$this->assertSame(1, $httpClient->getRequestsCount());
|
||||
}
|
||||
|
||||
public function testStreamingConverterWithDirectResponse()
|
||||
{
|
||||
$streamingData = 'data: '.json_encode([
|
||||
'model' => 'llama3.2',
|
||||
'created_at' => '2025-08-23T10:00:00Z',
|
||||
'message' => ['role' => 'assistant', 'content' => 'Hello'],
|
||||
'done' => false,
|
||||
])."\n\n".
|
||||
'data: '.json_encode([
|
||||
'model' => 'llama3.2',
|
||||
'created_at' => '2025-08-23T10:00:01Z',
|
||||
'message' => ['role' => 'assistant', 'content' => ' world'],
|
||||
'done' => true,
|
||||
])."\n\n";
|
||||
|
||||
$mockHttpClient = new MockHttpClient([
|
||||
new MockResponse($streamingData, [
|
||||
'response_headers' => [
|
||||
'content-type' => 'text/event-stream',
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$mockResponse = $mockHttpClient->request('GET', 'http://test.example');
|
||||
$rawResult = new RawHttpResult($mockResponse);
|
||||
$converter = new OllamaResultConverter();
|
||||
|
||||
$result = $converter->convert($rawResult, ['stream' => true]);
|
||||
|
||||
$this->assertInstanceOf(StreamResult::class, $result);
|
||||
$this->assertInstanceOf(\Generator::class, $result->getContent());
|
||||
|
||||
$regularMockHttpClient = new MockHttpClient([
|
||||
new JsonMockResponse([
|
||||
'model' => 'llama3.2',
|
||||
'message' => ['role' => 'assistant', 'content' => 'Hello world'],
|
||||
'done' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
$regularMockResponse = $regularMockHttpClient->request('GET', 'http://test.example');
|
||||
$regularRawResult = new RawHttpResult($regularMockResponse);
|
||||
$regularResult = $converter->convert($regularRawResult, ['stream' => false]);
|
||||
|
||||
$this->assertNotInstanceOf(StreamResult::class, $regularResult);
|
||||
}
|
||||
}
|
||||
235
Tests/OllamaResultConverterTest.php
Normal file
235
Tests/OllamaResultConverterTest.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?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\Ollama\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Platform\Bridge\Ollama\Ollama;
|
||||
use Symfony\AI\Platform\Bridge\Ollama\OllamaMessageChunk;
|
||||
use Symfony\AI\Platform\Bridge\Ollama\OllamaResultConverter;
|
||||
use Symfony\AI\Platform\Exception\RuntimeException;
|
||||
use Symfony\AI\Platform\Model;
|
||||
use Symfony\AI\Platform\Result\InMemoryRawResult;
|
||||
use Symfony\AI\Platform\Result\RawHttpResult;
|
||||
use Symfony\AI\Platform\Result\StreamResult;
|
||||
use Symfony\AI\Platform\Result\TextResult;
|
||||
use Symfony\AI\Platform\Result\ToolCallResult;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
final class OllamaResultConverterTest extends TestCase
|
||||
{
|
||||
public function testSupportsLlamaModel()
|
||||
{
|
||||
$converter = new OllamaResultConverter();
|
||||
|
||||
$this->assertTrue($converter->supports(new Ollama('llama3.2')));
|
||||
$this->assertFalse($converter->supports(new Model('any-model')));
|
||||
}
|
||||
|
||||
public function testConvertTextResponse()
|
||||
{
|
||||
$converter = new OllamaResultConverter();
|
||||
$rawResult = new InMemoryRawResult([
|
||||
'message' => [
|
||||
'content' => 'Hello world',
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $converter->convert($rawResult);
|
||||
|
||||
$this->assertInstanceOf(TextResult::class, $result);
|
||||
$this->assertSame('Hello world', $result->getContent());
|
||||
}
|
||||
|
||||
public function testConvertToolCallResponse()
|
||||
{
|
||||
$converter = new OllamaResultConverter();
|
||||
$rawResult = new InMemoryRawResult([
|
||||
'message' => [
|
||||
'content' => 'This content will be ignored because tool_calls are present',
|
||||
'tool_calls' => [
|
||||
[
|
||||
'function' => [
|
||||
'name' => 'test_function',
|
||||
'arguments' => ['arg1' => 'value1'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $converter->convert($rawResult);
|
||||
|
||||
$this->assertInstanceOf(ToolCallResult::class, $result);
|
||||
$toolCalls = $result->getContent();
|
||||
$this->assertCount(1, $toolCalls);
|
||||
$this->assertSame('0', $toolCalls[0]->getId()); // ID is the array index as a string
|
||||
$this->assertSame('test_function', $toolCalls[0]->getName());
|
||||
$this->assertSame(['arg1' => 'value1'], $toolCalls[0]->getArguments());
|
||||
}
|
||||
|
||||
public function testConvertMultipleToolCallsResponse()
|
||||
{
|
||||
$converter = new OllamaResultConverter();
|
||||
$rawResult = new InMemoryRawResult([
|
||||
'message' => [
|
||||
'content' => 'This content will be ignored because tool_calls are present',
|
||||
'tool_calls' => [
|
||||
[
|
||||
'function' => [
|
||||
'name' => 'function1',
|
||||
'arguments' => ['param1' => 'value1'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'function' => [
|
||||
'name' => 'function2',
|
||||
'arguments' => ['param2' => 'value2'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $converter->convert($rawResult);
|
||||
|
||||
$this->assertInstanceOf(ToolCallResult::class, $result);
|
||||
$toolCalls = $result->getContent();
|
||||
$this->assertCount(2, $toolCalls);
|
||||
|
||||
$this->assertSame('0', $toolCalls[0]->getId());
|
||||
$this->assertSame('function1', $toolCalls[0]->getName());
|
||||
$this->assertSame(['param1' => 'value1'], $toolCalls[0]->getArguments());
|
||||
|
||||
$this->assertSame('1', $toolCalls[1]->getId());
|
||||
$this->assertSame('function2', $toolCalls[1]->getName());
|
||||
$this->assertSame(['param2' => 'value2'], $toolCalls[1]->getArguments());
|
||||
}
|
||||
|
||||
public function testThrowsExceptionWhenNoMessage()
|
||||
{
|
||||
$converter = new OllamaResultConverter();
|
||||
$rawResult = new InMemoryRawResult([]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Response does not contain message');
|
||||
|
||||
$converter->convert($rawResult);
|
||||
}
|
||||
|
||||
public function testThrowsExceptionWhenNoContent()
|
||||
{
|
||||
$converter = new OllamaResultConverter();
|
||||
$rawResult = new InMemoryRawResult([
|
||||
'message' => [],
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Message does not contain content');
|
||||
|
||||
$converter->convert($rawResult);
|
||||
}
|
||||
|
||||
public function testItConvertsAResponseToAVectorResult()
|
||||
{
|
||||
$result = $this->createStub(ResponseInterface::class);
|
||||
$result
|
||||
->method('toArray')
|
||||
->willReturn([
|
||||
'model' => 'all-minilm',
|
||||
'embeddings' => [
|
||||
[0.3, 0.4, 0.4],
|
||||
[0.0, 0.0, 0.2],
|
||||
],
|
||||
'total_duration' => 14143917,
|
||||
'load_duration' => 1019500,
|
||||
'prompt_eval_count' => 8,
|
||||
]);
|
||||
|
||||
$vectorResult = (new OllamaResultConverter())->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());
|
||||
}
|
||||
|
||||
public function testConvertStreamingResponse()
|
||||
{
|
||||
$converter = new OllamaResultConverter();
|
||||
$rawResult = new InMemoryRawResult(dataStream: $this->generateConvertStreamingStream());
|
||||
|
||||
$result = $converter->convert($rawResult, options: ['stream' => true]);
|
||||
|
||||
$this->assertInstanceOf(StreamResult::class, $result);
|
||||
|
||||
$chunks = $result->getContent();
|
||||
$this->assertInstanceOf(OllamaMessageChunk::class, $chunks->current());
|
||||
$this->assertSame('Hello', $chunks->current()->getContent());
|
||||
$this->assertFalse($chunks->current()->isDone());
|
||||
$this->assertSame('deepseek-r1:latest', $chunks->current()->raw['model']);
|
||||
$this->assertArrayNotHasKey('total_duration', $chunks->current()->raw);
|
||||
$chunks->next();
|
||||
$this->assertInstanceOf(OllamaMessageChunk::class, $chunks->current());
|
||||
$this->assertSame(' world!', $chunks->current()->getContent());
|
||||
$this->assertTrue($chunks->current()->isDone());
|
||||
$this->assertArrayHasKey('total_duration', $chunks->current()->raw);
|
||||
}
|
||||
|
||||
public function testConvertThinkingStreamingResponse()
|
||||
{
|
||||
$converter = new OllamaResultConverter();
|
||||
$rawResult = new InMemoryRawResult(dataStream: $this->generateConvertThinkingStreamingStream());
|
||||
|
||||
$result = $converter->convert($rawResult, options: ['stream' => true]);
|
||||
|
||||
$this->assertInstanceOf(StreamResult::class, $result);
|
||||
|
||||
$chunks = $result->getContent();
|
||||
$this->assertInstanceOf(OllamaMessageChunk::class, $chunks->current());
|
||||
$this->assertSame('', $chunks->current()->getContent());
|
||||
$this->assertSame('Thinking', $chunks->current()->getThinking());
|
||||
$this->assertFalse($chunks->current()->isDone());
|
||||
$this->assertSame('deepseek-r1:latest', $chunks->current()->raw['model']);
|
||||
$this->assertArrayNotHasKey('total_duration', $chunks->current()->raw);
|
||||
$chunks->next();
|
||||
$this->assertSame('', $chunks->current()->getContent());
|
||||
$this->assertSame(' hard', $chunks->current()->getThinking());
|
||||
$this->assertFalse($chunks->current()->isDone());
|
||||
$chunks->next();
|
||||
$this->assertSame('Hello', $chunks->current()->getContent());
|
||||
$this->assertNull($chunks->current()->getThinking());
|
||||
$this->assertFalse($chunks->current()->isDone());
|
||||
$chunks->next();
|
||||
$this->assertInstanceOf(OllamaMessageChunk::class, $chunks->current());
|
||||
$this->assertSame(' world!', $chunks->current()->getContent());
|
||||
$this->assertNull($chunks->current()->getThinking());
|
||||
$this->assertTrue($chunks->current()->isDone());
|
||||
$this->assertArrayHasKey('total_duration', $chunks->current()->raw);
|
||||
}
|
||||
|
||||
private function generateConvertStreamingStream(): iterable
|
||||
{
|
||||
yield ['model' => 'deepseek-r1:latest', 'created_at' => '2025-10-29T17:15:49.631700779Z', 'message' => ['role' => 'assistant', 'content' => 'Hello'], 'done' => false];
|
||||
yield ['model' => 'deepseek-r1:latest', 'created_at' => '2025-10-29T17:15:49.905924913Z', 'message' => ['role' => 'assistant', 'content' => ' world!'], 'done' => true,
|
||||
'done_reason' => 'stop', 'total_duration' => 100, 'load_duration' => 10, 'prompt_eval_count' => 42, 'prompt_eval_duration' => 30, 'eval_count' => 17, 'eval_duration' => 60];
|
||||
}
|
||||
|
||||
private function generateConvertThinkingStreamingStream(): iterable
|
||||
{
|
||||
yield ['model' => 'deepseek-r1:latest', 'created_at' => '2025-10-29T17:15:49.631700779Z', 'message' => ['role' => 'assistant', 'content' => '', 'thinking' => 'Thinking'], 'done' => false];
|
||||
yield ['model' => 'deepseek-r1:latest', 'created_at' => '2025-10-29T17:15:49.905924913Z', 'message' => ['role' => 'assistant', 'content' => '', 'thinking' => ' hard'], 'done' => false];
|
||||
yield ['model' => 'deepseek-r1:latest', 'created_at' => '2025-10-29T17:15:50.14497475Z', 'message' => ['role' => 'assistant', 'content' => 'Hello'], 'done' => false];
|
||||
yield ['model' => 'deepseek-r1:latest', 'created_at' => '2025-10-29T17:15:50.367912083Z', 'message' => ['role' => 'assistant', 'content' => ' world!'], 'done' => true,
|
||||
'done_reason' => 'stop', 'total_duration' => 100, 'load_duration' => 10, 'prompt_eval_count' => 42, 'prompt_eval_duration' => 30, 'eval_count' => 17, 'eval_duration' => 60];
|
||||
}
|
||||
}
|
||||
88
Tests/TokenUsageExtractorTest.php
Normal file
88
Tests/TokenUsageExtractorTest.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?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\Ollama\Tests;
|
||||
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Platform\Bridge\Ollama\TokenUsageExtractor;
|
||||
use Symfony\AI\Platform\Result\InMemoryRawResult;
|
||||
use Symfony\AI\Platform\TokenUsage\TokenUsage;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
final class TokenUsageExtractorTest extends TestCase
|
||||
{
|
||||
public function testItHandlesStreamResponsesWithoutProcessing()
|
||||
{
|
||||
$extractor = new TokenUsageExtractor();
|
||||
|
||||
$this->assertNull($extractor->extract(new InMemoryRawResult(), ['stream' => true]));
|
||||
}
|
||||
|
||||
public function testItDoesNothingWithoutUsageData()
|
||||
{
|
||||
$extractor = new TokenUsageExtractor();
|
||||
|
||||
$this->assertNull($extractor->extract(new InMemoryRawResult(['some' => 'data'])));
|
||||
}
|
||||
|
||||
public function testItExtractsTokenUsage()
|
||||
{
|
||||
$extractor = new TokenUsageExtractor();
|
||||
$result = new InMemoryRawResult([], object: $this->createResponseObject());
|
||||
|
||||
$tokenUsage = $extractor->extract($result);
|
||||
|
||||
$this->assertInstanceOf(TokenUsage::class, $tokenUsage);
|
||||
$this->assertSame(10, $tokenUsage->getPromptTokens());
|
||||
$this->assertSame(10, $tokenUsage->getCompletionTokens());
|
||||
}
|
||||
|
||||
public function testItExtractsTokenUsageFromStreamResult()
|
||||
{
|
||||
$extractor = new TokenUsageExtractor();
|
||||
|
||||
$result = new InMemoryRawResult([], [
|
||||
[
|
||||
'model' => 'foo',
|
||||
'response' => 'First chunk',
|
||||
'done' => false,
|
||||
],
|
||||
[
|
||||
'model' => 'foo',
|
||||
'response' => 'Hello World!',
|
||||
'done' => true,
|
||||
'prompt_eval_count' => 10,
|
||||
'eval_count' => 10,
|
||||
],
|
||||
], object: $this->createResponseObject());
|
||||
|
||||
$tokenUsage = $extractor->extract($result, ['stream' => true]);
|
||||
|
||||
$this->assertInstanceOf(TokenUsage::class, $tokenUsage);
|
||||
$this->assertSame(10, $tokenUsage->getPromptTokens());
|
||||
$this->assertSame(10, $tokenUsage->getCompletionTokens());
|
||||
}
|
||||
|
||||
private function createResponseObject(): ResponseInterface|MockObject
|
||||
{
|
||||
$response = $this->createStub(ResponseInterface::class);
|
||||
$response->method('toArray')->willReturn([
|
||||
'model' => 'foo',
|
||||
'response' => 'Hello World!',
|
||||
'done' => true,
|
||||
'prompt_eval_count' => 10,
|
||||
'eval_count' => 10,
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
61
composer.json
Normal file
61
composer.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "symfony/ai-ollama-platform",
|
||||
"description": "Ollama platform bridge for Symfony AI",
|
||||
"license": "MIT",
|
||||
"type": "symfony-ai-platform",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"bridge",
|
||||
"local",
|
||||
"ollama",
|
||||
"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\\Ollama\\": ""
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Symfony\\AI\\PHPStan\\": "../../../../../.phpstan/",
|
||||
"Symfony\\AI\\Platform\\Bridge\\Ollama\\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
23
phpstan.dist.neon
Normal 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
32
phpunit.xml.dist
Normal 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 Ollama 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>
|
||||
Reference in New Issue
Block a user