refactor(ollama): remove ModelCatalog

This commit is contained in:
Guillaume Loulier
2026-03-13 17:58:02 +01:00
parent d9fb5ef744
commit b59aa9c038
24 changed files with 121 additions and 502 deletions

View File

@@ -1,6 +1,20 @@
UPGRADE FROM 0.6 to 0.7
=======================
AI Bundle
---------
* The `api_catalog` option for `Ollama` has been removed as the catalog is now automatically fetched from the Ollama server
* The `api_key` option for `Ollama` is now `null` by default to allow the usage of a `ScopingHttpClient`
* The `endpoint` option for `Ollama` is now `null` by default to allow the usage of a `ScopingHttpClient`
Platform
-----
* `ModelCatalog` in `Ollama` has been replaced by `OllamaApiCatalog`
* `OllamaApiCatalog` in `Ollama` has been replaced to `ModelCatalog`
* `Ollama` model is now `final`
Store
-----

View File

@@ -15,7 +15,7 @@ use Symfony\AI\Platform\Message\MessageBag;
require_once dirname(__DIR__).'/bootstrap.php';
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), httpClient: http_client());
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), env('OLLAMA_API_KEY'), httpClient: http_client());
try {
$result = $platform->invoke(env('OLLAMA_LLM'), new MessageBag(

View File

@@ -15,7 +15,7 @@ use Symfony\AI\Platform\Message\MessageBag;
require_once dirname(__DIR__).'/bootstrap.php';
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), httpClient: http_client());
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), env('OLLAMA_API_KEY'), httpClient: http_client());
$messages = new MessageBag(
Message::forSystem('You are a helpful assistant.'),

View File

@@ -13,7 +13,7 @@ use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory;
require_once dirname(__DIR__).'/bootstrap.php';
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), httpClient: http_client());
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), env('OLLAMA_API_KEY'), httpClient: http_client());
$response = $platform->invoke(env('OLLAMA_EMBEDDINGS'), <<<TEXT
Once upon a time, there was a country called Japan. It was a beautiful country with a lot of mountains and rivers.

View File

@@ -21,7 +21,8 @@ use Symfony\AI\Store\Query\VectorQuery;
require_once dirname(__DIR__).'/bootstrap.php';
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), httpClient: http_client());
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), env('OLLAMA_API_KEY'), httpClient: http_client());
$store = new InMemoryStore();
$vectorizer = new Vectorizer($platform, env('OLLAMA_EMBEDDINGS'), logger());
$indexer = new SourceIndexer(

View File

@@ -41,7 +41,7 @@ foreach (Movies::all() as $i => $movie) {
}
// create embeddings for documents
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), httpClient: http_client());
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), env('OLLAMA_API_KEY'), httpClient: http_client());
$vectorizer = new Vectorizer($platform, env('OLLAMA_EMBEDDINGS'), logger());
$indexer = new DocumentIndexer(new DocumentProcessor($vectorizer, $store, logger: logger()));
$indexer->index($documents);

View File

@@ -19,7 +19,7 @@ use Symfony\AI\Platform\Message\MessageBag;
require_once dirname(__DIR__).'/bootstrap.php';
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), httpClient: http_client());
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), env('OLLAMA_API_KEY'), httpClient: http_client());
$toolbox = new Toolbox([new Clock()], logger: logger());
$processor = new AgentProcessor($toolbox);

View File

@@ -15,7 +15,7 @@ use Symfony\AI\Platform\Message\MessageBag;
require_once dirname(__DIR__).'/bootstrap.php';
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), httpClient: http_client());
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), env('OLLAMA_API_KEY'), httpClient: http_client());
$messages = new MessageBag(
Message::forSystem('You are a helpful assistant.'),

View File

@@ -15,7 +15,7 @@ use Symfony\AI\Platform\Message\MessageBag;
require_once dirname(__DIR__).'/bootstrap.php';
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), httpClient: http_client());
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), env('OLLAMA_API_KEY'), httpClient: http_client());
$messages = new MessageBag(
Message::forSystem('You are a helpful assistant.'),

View File

@@ -21,7 +21,7 @@ require_once dirname(__DIR__).'/bootstrap.php';
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new PlatformSubscriber());
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), httpClient: http_client(), eventDispatcher: $dispatcher);
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), env('OLLAMA_API_KEY'), httpClient: http_client(), eventDispatcher: $dispatcher);
$messages = new MessageBag(
Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'),
Message::ofUser('how can I solve 8x + 7 = -23'),

View File

@@ -19,7 +19,7 @@ use Symfony\AI\Platform\Message\MessageBag;
require_once dirname(__DIR__).'/bootstrap.php';
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), httpClient: http_client());
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), env('OLLAMA_API_KEY'), httpClient: http_client());
$toolbox = new Toolbox([new Clock()], logger: logger());
$processor = new AgentProcessor($toolbox);

View File

@@ -1,6 +1,13 @@
CHANGELOG
=========
0.7
---
* The `api_catalog` option for `Ollama` has been removed as the catalog is now automatically fetched from the Ollama server
* The `api_key` option for `Ollama` is now `null` by default to allow the usage of a `ScopingHttpClient`
* The `endpoint` option for `Ollama` is now `null` by default to allow the usage of a `ScopingHttpClient`
0.6
---

View File

@@ -16,18 +16,13 @@ use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
return (new ArrayNodeDefinition('ollama'))
->children()
->stringNode('endpoint')
->defaultNull()
->info('Endpoint for Ollama (e.g. "http://127.0.0.1:11434" for local, or a cloud endpoint). If null, the http_client is used as-is and must already be configured with a base URI.')
->end()
->stringNode('api_key')
->defaultNull()
->info('API key for Ollama Cloud authentication (optional for local usage)')
->end()
->stringNode('http_client')
->defaultValue('http_client')
->info('Service ID of the HTTP client to use. When "endpoint" is null, this client must be pre-configured (e.g. with a base_uri).')
->end()
->booleanNode('api_catalog')
->info('If set, the Ollama API will be used to build the catalog and retrieve models information, using this option leads to additional HTTP calls')
->end()
->end();

View File

@@ -46,7 +46,6 @@ use Symfony\AI\Platform\Bridge\LmStudio\ModelCatalog as LmStudioModelCatalog;
use Symfony\AI\Platform\Bridge\Meta\ModelCatalog as MetaModelCatalog;
use Symfony\AI\Platform\Bridge\Mistral\ModelCatalog as MistralModelCatalog;
use Symfony\AI\Platform\Bridge\Ollama\Contract\OllamaContract;
use Symfony\AI\Platform\Bridge\Ollama\ModelCatalog as OllamaModelCatalog;
use Symfony\AI\Platform\Bridge\OpenAi\Contract\OpenAiContract;
use Symfony\AI\Platform\Bridge\OpenAi\ModelCatalog as OpenAiModelCatalog;
use Symfony\AI\Platform\Bridge\OpenRouter\ModelCatalog as OpenRouterModelCatalog;
@@ -122,9 +121,6 @@ return static function (ContainerConfigurator $container): void {
->set('ai.platform.model_catalog.lmstudio', LmStudioModelCatalog::class)
->set('ai.platform.model_catalog.meta', MetaModelCatalog::class)
->set('ai.platform.model_catalog.mistral', MistralModelCatalog::class)
->set('ai.platform.model_catalog.ollama', OllamaModelCatalog::class)
->lazy()
->tag('proxy', ['interface' => ModelCatalogInterface::class])
->set('ai.platform.model_catalog.openai', OpenAiModelCatalog::class)
->set('ai.platform.model_catalog.openrouter', OpenRouterModelCatalog::class)
->set('ai.platform.model_catalog.ovh', OvhModelCatalog::class)

View File

@@ -68,7 +68,7 @@ use Symfony\AI\Platform\Bridge\Generic\PlatformFactory as GenericPlatformFactory
use Symfony\AI\Platform\Bridge\HuggingFace\PlatformFactory as HuggingFacePlatformFactory;
use Symfony\AI\Platform\Bridge\LmStudio\PlatformFactory as LmStudioPlatformFactory;
use Symfony\AI\Platform\Bridge\Mistral\PlatformFactory as MistralPlatformFactory;
use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
use Symfony\AI\Platform\Bridge\Ollama\ModelCatalog;
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory as OllamaPlatformFactory;
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory;
use Symfony\AI\Platform\Bridge\OpenRouter\PlatformFactory as OpenRouterPlatformFactory;
@@ -81,7 +81,6 @@ use Symfony\AI\Platform\Bridge\Voyage\PlatformFactory as VoyagePlatformFactory;
use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\Exception\RuntimeException;
use Symfony\AI\Platform\Message\Content\File;
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Platform;
use Symfony\AI\Platform\PlatformInterface;
@@ -137,7 +136,6 @@ use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\ScopingHttpClient;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Translation\TranslatableMessage;
@@ -849,47 +847,13 @@ final class AiBundle extends AbstractBundle
throw new RuntimeException('Ollama platform configuration requires "symfony/ai-ollama-platform" package. Try running "composer require symfony/ai-ollama-platform".');
}
$httpClientReference = new Reference($platform['http_client']);
if (null !== $platform['endpoint']) {
$defaultOptions = [];
if (null !== ($platform['api_key'] ?? null)) {
$defaultOptions['auth_bearer'] = $platform['api_key'];
}
$scopedClientDefinition = (new Definition(ScopingHttpClient::class))
->setFactory([ScopingHttpClient::class, 'forBaseUri'])
->setArguments([
$httpClientReference,
$platform['endpoint'],
$defaultOptions,
]);
$container->setDefinition('ai.platform.ollama.scoped_http_client', $scopedClientDefinition);
$httpClientReference = new Reference('ai.platform.ollama.scoped_http_client');
}
if (\array_key_exists('api_catalog', $platform)) {
$catalogDefinition = (new Definition(OllamaApiCatalog::class))
->setLazy(true)
->setArguments([
$httpClientReference,
])
->addTag('proxy', ['interface' => ModelCatalogInterface::class])
;
$container->setDefinition('ai.platform.model_catalog.ollama', $catalogDefinition);
}
$definition = (new Definition(Platform::class))
->setFactory(OllamaPlatformFactory::class.'::create')
->setLazy(true)
->setArguments([
$platform['endpoint'],
$platform['endpoint'] ?? null,
$platform['api_key'] ?? null,
$httpClientReference,
new Reference('ai.platform.model_catalog.ollama'),
new Reference($platform['http_client']),
new Reference('ai.platform.contract.ollama'),
new Reference('event_dispatcher'),
])

View File

@@ -38,8 +38,6 @@ use Symfony\AI\Platform\Bridge\ElevenLabs\ModelCatalog as ElevenLabsModelCatalog
use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory as ElevenLabsPlatformFactory;
use Symfony\AI\Platform\Bridge\Failover\FailoverPlatform;
use Symfony\AI\Platform\Bridge\Failover\FailoverPlatformFactory;
use Symfony\AI\Platform\Bridge\Ollama\ModelCatalog;
use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory as OllamaPlatformFactory;
use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\EventListener\TemplateRendererListener;
@@ -3912,35 +3910,24 @@ class AiBundleTest extends TestCase
]);
$this->assertTrue($container->hasDefinition('ai.platform.ollama'));
$this->assertTrue($container->hasDefinition('ai.platform.ollama.scoped_http_client'));
$this->assertTrue($container->hasDefinition('ai.platform.model_catalog.ollama'));
$definition = $container->getDefinition('ai.platform.ollama');
$this->assertSame([OllamaPlatformFactory::class, 'create'], $definition->getFactory());
$this->assertTrue($definition->isLazy());
$this->assertCount(6, $definition->getArguments());
$this->assertCount(5, $definition->getArguments());
$this->assertSame('http://127.0.0.1:11434', $definition->getArgument(0));
$this->assertNull($definition->getArgument(1));
$this->assertInstanceOf(Reference::class, $definition->getArgument(2));
$this->assertSame('ai.platform.ollama.scoped_http_client', (string) $definition->getArgument(2));
$this->assertSame('http_client', (string) $definition->getArgument(2));
$this->assertInstanceOf(Reference::class, $definition->getArgument(3));
$this->assertSame('ai.platform.model_catalog.ollama', (string) $definition->getArgument(3));
$this->assertSame('ai.platform.contract.ollama', (string) $definition->getArgument(3));
$this->assertInstanceOf(Reference::class, $definition->getArgument(4));
$this->assertSame('ai.platform.contract.ollama', (string) $definition->getArgument(4));
$this->assertInstanceOf(Reference::class, $definition->getArgument(5));
$this->assertSame('event_dispatcher', (string) $definition->getArgument(5));
$this->assertSame('event_dispatcher', (string) $definition->getArgument(4));
$this->assertTrue($definition->hasTag('proxy'));
$this->assertSame([['interface' => PlatformInterface::class]], $definition->getTag('proxy'));
$catalogDefinition = $container->getDefinition('ai.platform.model_catalog.ollama');
$this->assertTrue($catalogDefinition->isLazy());
$this->assertSame(ModelCatalog::class, $catalogDefinition->getClass());
$this->assertTrue($catalogDefinition->hasTag('proxy'));
$this->assertSame([['interface' => ModelCatalogInterface::class]], $catalogDefinition->getTag('proxy'));
// Ollama.com usage (with API key)
$container = $this->buildContainer([
'ai' => [
@@ -3954,35 +3941,24 @@ class AiBundleTest extends TestCase
]);
$this->assertTrue($container->hasDefinition('ai.platform.ollama'));
$this->assertTrue($container->hasDefinition('ai.platform.ollama.scoped_http_client'));
$this->assertTrue($container->hasDefinition('ai.platform.model_catalog.ollama'));
$definition = $container->getDefinition('ai.platform.ollama');
$this->assertSame([OllamaPlatformFactory::class, 'create'], $definition->getFactory());
$this->assertTrue($definition->isLazy());
$this->assertCount(6, $definition->getArguments());
$this->assertCount(5, $definition->getArguments());
$this->assertSame('https://ollama.com', $definition->getArgument(0));
$this->assertSame('foo', $definition->getArgument(1));
$this->assertInstanceOf(Reference::class, $definition->getArgument(2));
$this->assertSame('ai.platform.ollama.scoped_http_client', (string) $definition->getArgument(2));
$this->assertSame('http_client', (string) $definition->getArgument(2));
$this->assertInstanceOf(Reference::class, $definition->getArgument(3));
$this->assertSame('ai.platform.model_catalog.ollama', (string) $definition->getArgument(3));
$this->assertSame('ai.platform.contract.ollama', (string) $definition->getArgument(3));
$this->assertInstanceOf(Reference::class, $definition->getArgument(4));
$this->assertSame('ai.platform.contract.ollama', (string) $definition->getArgument(4));
$this->assertInstanceOf(Reference::class, $definition->getArgument(5));
$this->assertSame('event_dispatcher', (string) $definition->getArgument(5));
$this->assertSame('event_dispatcher', (string) $definition->getArgument(4));
$this->assertTrue($definition->hasTag('proxy'));
$this->assertSame([['interface' => PlatformInterface::class]], $definition->getTag('proxy'));
$catalogDefinition = $container->getDefinition('ai.platform.model_catalog.ollama');
$this->assertTrue($catalogDefinition->isLazy());
$this->assertSame(ModelCatalog::class, $catalogDefinition->getClass());
$this->assertTrue($catalogDefinition->hasTag('proxy'));
$this->assertSame([['interface' => ModelCatalogInterface::class]], $catalogDefinition->getTag('proxy'));
// Custom HTTPClient
$container = $this->buildContainer([
'ai' => [
@@ -3995,82 +3971,23 @@ class AiBundleTest extends TestCase
]);
$this->assertTrue($container->hasDefinition('ai.platform.ollama'));
$this->assertFalse($container->hasDefinition('ai.platform.ollama.scoped_http_client'));
$this->assertTrue($container->hasDefinition('ai.platform.model_catalog.ollama'));
$definition = $container->getDefinition('ai.platform.ollama');
$this->assertSame([OllamaPlatformFactory::class, 'create'], $definition->getFactory());
$this->assertTrue($definition->isLazy());
$this->assertCount(6, $definition->getArguments());
$this->assertCount(5, $definition->getArguments());
$this->assertNull($definition->getArgument(0));
$this->assertNull($definition->getArgument(1));
$this->assertInstanceOf(Reference::class, $definition->getArgument(2));
$this->assertSame('foo', (string) $definition->getArgument(2));
$this->assertInstanceOf(Reference::class, $definition->getArgument(3));
$this->assertSame('ai.platform.model_catalog.ollama', (string) $definition->getArgument(3));
$this->assertSame('ai.platform.contract.ollama', (string) $definition->getArgument(3));
$this->assertInstanceOf(Reference::class, $definition->getArgument(4));
$this->assertSame('ai.platform.contract.ollama', (string) $definition->getArgument(4));
$this->assertInstanceOf(Reference::class, $definition->getArgument(5));
$this->assertSame('event_dispatcher', (string) $definition->getArgument(5));
$this->assertSame('event_dispatcher', (string) $definition->getArgument(4));
$this->assertTrue($definition->hasTag('proxy'));
$this->assertSame([['interface' => PlatformInterface::class]], $definition->getTag('proxy'));
$catalogDefinition = $container->getDefinition('ai.platform.model_catalog.ollama');
$this->assertTrue($catalogDefinition->isLazy());
$this->assertSame(ModelCatalog::class, $catalogDefinition->getClass());
$this->assertTrue($catalogDefinition->hasTag('proxy'));
$this->assertSame([['interface' => ModelCatalogInterface::class]], $catalogDefinition->getTag('proxy'));
}
public function testOllamaCanBeCreatedWithCatalogFromApi()
{
$container = $this->buildContainer([
'ai' => [
'platform' => [
'ollama' => [
'endpoint' => 'http://127.0.0.1:11434',
'api_catalog' => true,
],
],
],
]);
$this->assertTrue($container->hasDefinition('ai.platform.ollama'));
$this->assertTrue($container->hasDefinition('ai.platform.ollama.scoped_http_client'));
$this->assertTrue($container->hasDefinition('ai.platform.model_catalog.ollama'));
$definition = $container->getDefinition('ai.platform.ollama');
$this->assertSame([OllamaPlatformFactory::class, 'create'], $definition->getFactory());
$this->assertTrue($definition->isLazy());
$this->assertCount(6, $definition->getArguments());
$this->assertSame('http://127.0.0.1:11434', $definition->getArgument(0));
$this->assertNull($definition->getArgument(1));
$this->assertInstanceOf(Reference::class, $definition->getArgument(2));
$this->assertSame('ai.platform.ollama.scoped_http_client', (string) $definition->getArgument(2));
$this->assertInstanceOf(Reference::class, $definition->getArgument(3));
$this->assertSame('ai.platform.model_catalog.ollama', (string) $definition->getArgument(3));
$this->assertInstanceOf(Reference::class, $definition->getArgument(4));
$this->assertSame('ai.platform.contract.ollama', (string) $definition->getArgument(4));
$this->assertInstanceOf(Reference::class, $definition->getArgument(5));
$this->assertSame('event_dispatcher', (string) $definition->getArgument(5));
$this->assertTrue($definition->hasTag('proxy'));
$this->assertSame([['interface' => PlatformInterface::class]], $definition->getTag('proxy'));
$catalogDefinition = $container->getDefinition('ai.platform.model_catalog.ollama');
$this->assertTrue($catalogDefinition->isLazy());
$this->assertSame(OllamaApiCatalog::class, $catalogDefinition->getClass());
$this->assertCount(1, $catalogDefinition->getArguments());
$this->assertInstanceOf(Reference::class, $catalogDefinition->getArgument(0));
$this->assertSame('ai.platform.ollama.scoped_http_client', (string) $catalogDefinition->getArgument(0));
$this->assertTrue($catalogDefinition->hasTag('proxy'));
$this->assertSame([['interface' => ModelCatalogInterface::class]], $catalogDefinition->getTag('proxy'));
}
/**

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()