[Platform] Split bridges into dedicated packages

This commit is contained in:
Oskar Stark
2025-12-09 21:32:18 +01:00
parent 7507d501e5
commit 6676bc09be
10 changed files with 918 additions and 0 deletions

12
README.md Normal file
View 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)

View 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'],
],
],
],
],
];
}
}

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\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();
}
}

View 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
View 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);
}
}

View 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];
}
}

View 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
View 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
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 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>