Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/components/platform.rst
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,34 @@
// Get model with size variant and query parameters
$model = $catalog->getModel('qwen3:32b?temperature=0.5&top_p=0.9');

Custom models
~~~~~~~~~~~~~

For providers like Ollama, you can use custom models (built on top of ``Modelfile``), as those models are not listed in
the default catalog, you can use the built-in ``OllamaApiCatalog`` to query the model informations from the API rather

Check failure on line 84 in docs/components/platform.rst

View workflow job for this annotation

GitHub Actions / DOCtor-RST

Please replace "informations" with "information"
than the default catalog::

use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;

$platform = PlatformFactory::create('http://127.0.0.11434', HttpClient::create(), new OllamaApiCatalog(
'http://127.0.0.11434',
HttpClient::create(),
));

$platform->invoke('your_custom_model_name', new MessageBag(
Message::ofUser(...)
));

When using the bundle, the usage of ``OllamaApiCatalog`` is available via the ``api_catalog`` option::

ai:
platform:
ollama:
api_catalog: true

Supported Models & Platforms
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
34 changes: 34 additions & 0 deletions examples/ollama/chat-llama-api-catalog.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;

require_once dirname(__DIR__).'/bootstrap.php';

$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), http_client(), new OllamaApiCatalog(
env('OLLAMA_HOST_URL'),
http_client(),
));

$messages = new MessageBag(
Message::forSystem('You are a helpful assistant.'),
Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'),
);

try {
$result = $platform->invoke(env('OLLAMA_LLM'), $messages);
echo $result->asText().\PHP_EOL;
} catch (InvalidArgumentException $e) {
echo $e->getMessage()."\nMaybe use a different model?\n";
}
3 changes: 3 additions & 0 deletions src/ai-bundle/config/options.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@
->defaultValue('http_client')
->info('Service ID of the HTTP client to use')
->end()
->booleanNode('api_catalog')
->info('If set, the Ollama API will be used to build the catalog and retrieve models informations, using this option lead to additional HTTP calls')
->end()
->end()
->end()
->arrayNode('cerebras')
Expand Down
17 changes: 14 additions & 3 deletions src/ai-bundle/src/AiBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
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\PlatformFactory as OllamaPlatformFactory;
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory;
use Symfony\AI\Platform\Bridge\OpenRouter\PlatformFactory as OpenRouterPlatformFactory;
Expand Down Expand Up @@ -564,21 +565,31 @@ private function processPlatformConfig(string $type, array $platform, ContainerB
}

if ('ollama' === $type) {
$platformId = 'ai.platform.ollama';
if (\array_key_exists('api_catalog', $platform)) {
$catalogDefinition = (new Definition(OllamaApiCatalog::class))
->setLazy(true)
->setArguments([
$platform['host_url'],
new Reference('http_client'),
]);

$container->setDefinition('ai.platform.model_catalog.ollama', $catalogDefinition);
}

$definition = (new Definition(Platform::class))
->setFactory(OllamaPlatformFactory::class.'::create')
->setLazy(true)
->addTag('proxy', ['interface' => PlatformInterface::class])
->setArguments([
$platform['host_url'],
new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE),
new Reference('ai.platform.model_catalog.ollama'),
new Reference('ai.platform.contract.ollama'),
new Reference('event_dispatcher'),
])
->addTag('proxy', ['interface' => PlatformInterface::class])
->addTag('ai.platform', ['name' => 'ollama']);

$container->setDefinition($platformId, $definition);
$container->setDefinition('ai.platform.ollama', $definition);

return;
}
Expand Down
40 changes: 40 additions & 0 deletions src/ai-bundle/tests/DependencyInjection/AiBundleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use Symfony\AI\AiBundle\AiBundle;
use Symfony\AI\Chat\ChatInterface;
use Symfony\AI\Chat\MessageStoreInterface;
use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
use Symfony\AI\Store\Document\Filter\TextContainsFilter;
use Symfony\AI\Store\Document\Loader\InMemoryLoader;
use Symfony\AI\Store\Document\Transformer\TextTrimTransformer;
Expand Down Expand Up @@ -579,6 +580,45 @@ public function testConfigurationWithUseAttributeAsKeyWorksWithoutNormalizeKeys(
$this->assertTrue($container->hasDefinition('ai.store.mongodb.Production_DB-v3'));
}

public function testOllamaCanBeCreatedWithCatalogFromApi()
{
$container = $this->buildContainer([
'ai' => [
'platform' => [
'ollama' => [
'use_api_as_catalog' => true,
],
],
],
]);

$this->assertTrue($container->hasDefinition('ai.platform.ollama'));
$this->assertTrue($container->hasDefinition('ai.platform.model_catalog.ollama'));

$ollamaDefinition = $container->getDefinition('ai.platform.ollama');

$this->assertTrue($ollamaDefinition->isLazy());
$this->assertCount(4, $ollamaDefinition->getArguments());
$this->assertSame('http://127.0.0.1:11434', $ollamaDefinition->getArgument(0));
$this->assertInstanceOf(Reference::class, $ollamaDefinition->getArgument(1));
$this->assertSame('http_client', (string) $ollamaDefinition->getArgument(1));
$this->assertInstanceOf(Reference::class, $ollamaDefinition->getArgument(2));
$this->assertSame('ai.platform.model_catalog.ollama', (string) $ollamaDefinition->getArgument(2));
$this->assertInstanceOf(Reference::class, $ollamaDefinition->getArgument(3));
$this->assertSame('ai.platform.contract.ollama', (string) $ollamaDefinition->getArgument(3));

$ollamaCatalogDefinition = $container->getDefinition('ai.platform.model_catalog.ollama');

$this->assertTrue($ollamaCatalogDefinition->isLazy());
$this->assertSame(OllamaApiCatalog::class, $ollamaCatalogDefinition->getClass());
$this->assertCount(3, $ollamaCatalogDefinition->getArguments());
$this->assertSame('http://127.0.0.1:11434', $ollamaCatalogDefinition->getArgument(0));
$this->assertInstanceOf(Reference::class, $ollamaCatalogDefinition->getArgument(1));
$this->assertSame('http_client', (string) $ollamaCatalogDefinition->getArgument(1));
$this->assertInstanceOf(Reference::class, $ollamaCatalogDefinition->getArgument(2));
$this->assertSame('.inner', (string) $ollamaCatalogDefinition->getArgument(2));
}

/**
* Tests that processor tags use the full agent ID (ai.agent.my_agent) instead of just the agent name (my_agent).
* This regression test prevents issues where processors would not be correctly associated with their agents.
Expand Down
5 changes: 4 additions & 1 deletion src/platform/src/Bridge/Ollama/ModelCatalog.php
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ public function __construct(array $additionalModels = [])
],
];

$this->models = array_merge($defaultModels, $additionalModels);
$this->models = [
...$defaultModels,
...$additionalModels,
];
}
}
78 changes: 78 additions & 0 deletions src/platform/src/Bridge/Ollama/OllamaApiCatalog.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?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\FallbackModelCatalog;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* @author Guillaume Loulier <personal@guillaumeloulier.fr>
*/
final class OllamaApiCatalog extends FallbackModelCatalog
{
public function __construct(
private readonly string $host,
private readonly HttpClientInterface $httpClient,
) {
parent::__construct();
}

public function getModel(string $modelName): Ollama
{
$model = parent::getModel($modelName);

if (\array_key_exists($model->getName(), $this->models)) {
$finalModel = $this->models[$model->getName()];

return new $finalModel['class'](
$model->getName(),
$finalModel['capabilities'],
$model->getOptions(),
);
}

$response = $this->httpClient->request('POST', \sprintf('%s/api/show', $this->host), [
'json' => [
'model' => $model->getName(),
],
]);

$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_TEXT,
'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'],
);

$finalModel = new Ollama($model->getName(), $capabilities, $model->getOptions());

$this->models[$finalModel->getName()] = [
'class' => Ollama::class,
'capabilities' => $finalModel->getCapabilities(),
];

return $finalModel;
}
}
17 changes: 3 additions & 14 deletions src/platform/src/Bridge/Ollama/OllamaClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\AI\Platform\Bridge\Ollama;

use Symfony\AI\Platform\Capability;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
Expand All @@ -35,21 +36,9 @@ public function supports(Model $model): bool

public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
{
$response = $this->httpClient->request('POST', \sprintf('%s/api/show', $this->hostUrl), [
'json' => [
'model' => $model->getName(),
],
]);

$capabilities = $response->toArray()['capabilities'] ?? null;

if (null === $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.');
}

return match (true) {
\in_array('completion', $capabilities, true) => $this->doCompletionRequest($payload, $options),
\in_array('embedding', $capabilities, true) => $this->doEmbeddingsRequest($model, $payload, $options),
\in_array(Capability::INPUT_TEXT, $model->getCapabilities(), true) => $this->doCompletionRequest($payload, $options),
\in_array(Capability::EMBEDDINGS, $model->getCapabilities(), true) => $this->doEmbeddingsRequest($model, $payload, $options),
default => throw new InvalidArgumentException(\sprintf('Unsupported model "%s": "%s".', $model::class, $model->getName())),
};
}
Expand Down
6 changes: 6 additions & 0 deletions src/platform/src/Capability.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,10 @@ enum Capability: string
// VOICE
case TEXT_TO_SPEECH = 'text-to-speech';
case SPEECH_TO_TEXT = 'speech-to-text';

// EMBEDDINGS
case EMBEDDINGS = 'embeddings';

// Thinking
case THINKING = 'thinking';
}
38 changes: 38 additions & 0 deletions src/platform/tests/Bridge/Ollama/OllamaApiCatalogTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?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\Tests\Bridge\Ollama;

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\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, new ModelCatalog());

$model = $modelCatalog->getModel('foo');

$this->assertInstanceOf(Ollama::class, $model);
$this->assertSame(1, $httpClient->getRequestsCount());
}
}
14 changes: 6 additions & 8 deletions src/platform/tests/Bridge/Ollama/OllamaClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
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;
Expand All @@ -36,9 +37,6 @@ public function testSupportsModel()
public function testOutputStructureIsSupported()
{
$httpClient = new MockHttpClient([
new JsonMockResponse([
'capabilities' => ['completion', 'tools'],
]),
new JsonMockResponse([
'model' => 'foo',
'response' => [
Expand All @@ -50,7 +48,10 @@ public function testOutputStructureIsSupported()
], 'http://127.0.0.1:1234');

$client = new OllamaClient($httpClient, 'http://127.0.0.1:1234');
$response = $client->request(new Ollama('llama3.2'), [
$response = $client->request(new Ollama('llama3.2', [
Capability::COMPLETION,
Capability::TOOLS,
]), [
'messages' => [
[
'role' => 'user',
Expand All @@ -77,7 +78,7 @@ public function testOutputStructureIsSupported()
],
]);

$this->assertSame(2, $httpClient->getRequestsCount());
$this->assertSame(1, $httpClient->getRequestsCount());
$this->assertSame([
'model' => 'foo',
'response' => [
Expand All @@ -91,9 +92,6 @@ public function testOutputStructureIsSupported()
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',
Expand Down
Loading