refactor(ollama): remove ModelCatalog

This commit is contained in:
Guillaume Loulier
2026-03-13 17:58:02 +01:00
parent 08b0c6f177
commit 0435791001
8 changed files with 75 additions and 350 deletions

View File

@@ -5,6 +5,9 @@ CHANGELOG
---
* Add support for `structured_output` capability in `OllamaApiCatalog`
* Replace `ModelCatalog` by `OllamaApiCatalog`
* Rename `OllamaApiCatalog` to `ModelCatalog`
* [BC BREAK] `Ollama` model is now `final`
0.4
---

View File

@@ -12,212 +12,72 @@
namespace Symfony\AI\Platform\Bridge\Ollama;
use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelCatalog\AbstractModelCatalog;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @author Oskar Stark <oskarstark@googlemail.com>
* @author Guillaume Loulier <personal@guillaumeloulier.fr>
*/
final class ModelCatalog extends AbstractModelCatalog
final class ModelCatalog implements ModelCatalogInterface
{
/**
* @param array<string, array{class: class-string<Model>, capabilities: list<Capability>}> $additionalModels
*/
public function __construct(array $additionalModels = [])
{
$defaultModels = [
'deepseek-r1' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
Capability::TOOL_CALLING,
],
],
'gpt-oss' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
Capability::TOOL_CALLING,
],
],
'llama3.1' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
Capability::TOOL_CALLING,
],
],
'llama3.2' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
Capability::TOOL_CALLING,
],
],
'llama3' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
Capability::TOOL_CALLING,
],
],
'mistral' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
Capability::TOOL_CALLING,
],
],
'qwen3' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
Capability::TOOL_CALLING,
],
],
'qwen' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
Capability::TOOL_CALLING,
],
],
'qwen2' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
Capability::TOOL_CALLING,
],
],
'qwen2.5' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
Capability::TOOL_CALLING,
],
],
'qwen2.5-coder' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
Capability::TOOL_CALLING,
],
],
'gemma3n' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
],
],
'gemma3' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
],
],
'qwen2.5vl' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
],
],
'llava' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
],
],
'phi3' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
],
],
'gemma2' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
],
],
'gemma' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
],
],
'llama2' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_MESSAGES,
Capability::OUTPUT_TEXT,
Capability::OUTPUT_STRUCTURED,
],
],
'nomic-embed-text' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_TEXT,
Capability::INPUT_MULTIPLE,
Capability::EMBEDDINGS,
],
],
'bge-m3' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_TEXT,
Capability::INPUT_MULTIPLE,
Capability::EMBEDDINGS,
],
],
'all-minilm' => [
'class' => Ollama::class,
'capabilities' => [
Capability::INPUT_TEXT,
Capability::INPUT_MULTIPLE,
Capability::EMBEDDINGS,
],
],
];
public function __construct(
private readonly HttpClientInterface $httpClient,
) {
}
$this->models = [
...$defaultModels,
...$additionalModels,
];
public function getModel(string $modelName): Ollama
{
$response = $this->httpClient->request('POST', '/api/show', [
'json' => [
'model' => $modelName,
],
]);
$payload = $response->toArray();
if ([] === $payload['capabilities']) {
throw new InvalidArgumentException('The model information could not be retrieved from the Ollama API. Your Ollama server might be too old. Try upgrade it.');
}
$capabilities = array_map(
static fn (string $capability): Capability => match ($capability) {
'embedding' => Capability::EMBEDDINGS,
'completion' => Capability::INPUT_MESSAGES,
'tools' => Capability::TOOL_CALLING,
'thinking' => Capability::THINKING,
'vision' => Capability::INPUT_IMAGE,
default => throw new InvalidArgumentException(\sprintf('The "%s" capability is not supported', $capability)),
},
$payload['capabilities'],
);
if (!\in_array(Capability::EMBEDDINGS, $capabilities, true)) {
$capabilities[] = Capability::OUTPUT_STRUCTURED;
}
return new Ollama($modelName, $capabilities);
}
public function getModels(): array
{
$response = $this->httpClient->request('GET', '/api/tags');
$models = $response->toArray();
return array_merge(...array_map(
function (array $model): array {
$retrievedModel = $this->getModel($model['name']);
return [
$retrievedModel->getName() => [
'class' => Ollama::class,
'capabilities' => $retrievedModel->getCapabilities(),
],
];
},
$models['models'],
));
}
}

View File

@@ -16,6 +16,6 @@ use Symfony\AI\Platform\Model;
/**
* @author Joshua Behrens <code@joshua-behrens.de>
*/
class Ollama extends Model
final class Ollama extends Model
{
}

View File

@@ -1,82 +0,0 @@
<?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;
use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @author Guillaume Loulier <personal@guillaumeloulier.fr>
*/
final class OllamaApiCatalog implements ModelCatalogInterface
{
public function __construct(
private readonly HttpClientInterface $httpClient,
) {
}
public function getModel(string $modelName): Ollama
{
$response = $this->httpClient->request('POST', '/api/show', [
'json' => [
'model' => $modelName,
],
]);
$payload = $response->toArray();
if ([] === $payload['capabilities']) {
throw new InvalidArgumentException('The model information could not be retrieved from the Ollama API. Your Ollama server might be too old. Try upgrade it.');
}
$capabilities = array_map(
static fn (string $capability): Capability => match ($capability) {
'embedding' => Capability::EMBEDDINGS,
'completion' => Capability::INPUT_MESSAGES,
'tools' => Capability::TOOL_CALLING,
'thinking' => Capability::THINKING,
'vision' => Capability::INPUT_IMAGE,
default => throw new InvalidArgumentException(\sprintf('The "%s" capability is not supported', $capability)),
},
$payload['capabilities'],
);
if (!\in_array(Capability::EMBEDDINGS, $capabilities, true)) {
$capabilities[] = Capability::OUTPUT_STRUCTURED;
}
return new Ollama($modelName, $capabilities);
}
public function getModels(): array
{
$response = $this->httpClient->request('GET', '/api/tags');
$models = $response->toArray();
return array_merge(...array_map(
function (array $model): array {
$retrievedModel = $this->getModel($model['name']);
return [
$retrievedModel->getName() => [
'class' => Ollama::class,
'capabilities' => $retrievedModel->getCapabilities(),
],
];
},
$models['models'],
));
}
}

View File

@@ -13,7 +13,6 @@ namespace Symfony\AI\Platform\Bridge\Ollama;
use Symfony\AI\Platform\Bridge\Ollama\Contract\OllamaContract;
use Symfony\AI\Platform\Contract;
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
use Symfony\AI\Platform\Platform;
use Symfony\Component\HttpClient\EventSourceHttpClient;
use Symfony\Component\HttpClient\ScopingHttpClient;
@@ -26,10 +25,9 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
final class PlatformFactory
{
public static function create(
?string $endpoint = 'http://localhost:11434',
?string $endpoint = null,
#[\SensitiveParameter] ?string $apiKey = null,
?HttpClientInterface $httpClient = null,
ModelCatalogInterface $modelCatalog = new ModelCatalog(),
?Contract $contract = null,
?EventDispatcherInterface $eventDispatcher = null,
): Platform {
@@ -47,7 +45,7 @@ final class PlatformFactory
return new Platform(
[new OllamaClient($httpClient)],
[new OllamaResultConverter()],
$modelCatalog,
new ModelCatalog($httpClient),
$contract ?? OllamaContract::create(),
$eventDispatcher,
);

View File

@@ -1,57 +0,0 @@
<?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_TEXT, Capability::EMBEDDINGS, Capability::INPUT_MULTIPLE]];
yield 'bge-m3' => ['bge-m3', Ollama::class, [Capability::INPUT_TEXT, Capability::EMBEDDINGS, Capability::INPUT_MULTIPLE]];
yield 'all-minilm' => ['all-minilm', Ollama::class, [Capability::INPUT_TEXT, Capability::EMBEDDINGS, Capability::INPUT_MULTIPLE]];
yield 'all-minilm:33m' => ['all-minilm:33m', Ollama::class, [Capability::INPUT_TEXT, Capability::EMBEDDINGS, Capability::INPUT_MULTIPLE]];
}
protected function createModelCatalog(): ModelCatalogInterface
{
return new ModelCatalog();
}
}

View File

@@ -12,8 +12,8 @@
namespace Symfony\AI\Platform\Bridge\Ollama\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\AI\Platform\Bridge\Ollama\ModelCatalog;
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;
@@ -28,7 +28,7 @@ final class OllamaApiCatalogTest extends TestCase
]),
], 'http://127.0.0.1:11434');
$modelCatalog = new OllamaApiCatalog($httpClient);
$modelCatalog = new ModelCatalog($httpClient);
$model = $modelCatalog->getModel('foo');
@@ -56,7 +56,7 @@ final class OllamaApiCatalogTest extends TestCase
]),
], 'http://127.0.0.1:11434');
$modelCatalog = new OllamaApiCatalog($httpClient);
$modelCatalog = new ModelCatalog($httpClient);
$models = $modelCatalog->getModels();
@@ -88,7 +88,7 @@ final class OllamaApiCatalogTest extends TestCase
]),
], 'http://127.0.0.1:11434');
$modelCatalog = new OllamaApiCatalog($httpClient);
$modelCatalog = new ModelCatalog($httpClient);
$models = $modelCatalog->getModels();

View File

@@ -93,6 +93,9 @@ final class OllamaClientTest extends TestCase
public function testStreamingIsSupported()
{
$httpClient = new MockHttpClient([
new JsonMockResponse([
'capabilities' => ['completion'],
]),
new MockResponse('data: '.json_encode([
'model' => 'llama3.2',
'created_at' => '2025-08-23T10:00:00Z',
@@ -124,7 +127,7 @@ final class OllamaClientTest extends TestCase
$this->assertInstanceOf(StreamResult::class, $result);
$this->assertInstanceOf(\Generator::class, $result->getContent());
$this->assertSame(1, $httpClient->getRequestsCount());
$this->assertSame(2, $httpClient->getRequestsCount());
}
public function testStreamingConverterWithDirectResponse()