Skip to content

Commit 6a59f94

Browse files
committed
feature #766 [Platform][Ollama] Introduce OllamaApiCatalog (Guikingone)
This PR was squashed before being merged into the main branch. Discussion ---------- [Platform][Ollama] Introduce `OllamaApiCatalog` | Q | A | ------------- | --- | Bug fix? | yes | New feature? | yes | Docs? | yes | Issues | None | License | MIT Hi 👋🏻 It looks like when we're dealing with platforms that can list / detain multiple models at once, the default catalog is "a fixed one", when trying to add a `FallbackModelCatalog`, the platform fails to retrieve the model as most of them use a custom `Model` class, this PR aims to introduce / fix the issue with an extra argument. I know that the solution is not perfect, my main concern is the problem we're facing as we have custom models classes, that's the only way I found to prevent the error: <img width="542" height="24" alt="Capture d’écran 2025-10-09 à 17 52 28" src="https://github.com/user-attachments/assets/7e0a3a18-2e25-44d0-9c1e-c913fe25b42d" /> This approach allows to use any model stored in the platform (especially as the ones listed in the default catalog are not up-to-date). Commits ------- 7ccdfc8 [Platform][Ollama] Introduce `OllamaApiCatalog`
2 parents 19b05e3 + 7ccdfc8 commit 6a59f94

File tree

10 files changed

+223
-27
lines changed

10 files changed

+223
-27
lines changed

docs/components/platform.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,34 @@ You can also combine size variants with query parameters::
7777
// Get model with size variant and query parameters
7878
$model = $catalog->getModel('qwen3:32b?temperature=0.5&top_p=0.9');
7979

80+
Custom models
81+
~~~~~~~~~~~~~
82+
83+
For providers like Ollama, you can use custom models (built on top of ``Modelfile``), as those models are not listed in
84+
the default catalog, you can use the built-in ``OllamaApiCatalog`` to query the model information from the API rather
85+
than the default catalog::
86+
87+
use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
88+
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory;
89+
use Symfony\AI\Platform\Message\Message;
90+
use Symfony\AI\Platform\Message\MessageBag;
91+
92+
$platform = PlatformFactory::create('http://127.0.0.11434', HttpClient::create(), new OllamaApiCatalog(
93+
'http://127.0.0.11434',
94+
HttpClient::create(),
95+
));
96+
97+
$platform->invoke('your_custom_model_name', new MessageBag(
98+
Message::ofUser(...)
99+
));
100+
101+
When using the bundle, the usage of ``OllamaApiCatalog`` is available via the ``api_catalog`` option::
102+
103+
ai:
104+
platform:
105+
ollama:
106+
api_catalog: true
107+
80108
Supported Models & Platforms
81109
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
82110

src/ai-bundle/config/options.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@
160160
->defaultValue('http_client')
161161
->info('Service ID of the HTTP client to use')
162162
->end()
163+
->booleanNode('api_catalog')
164+
->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')
165+
->end()
163166
->end()
164167
->end()
165168
->arrayNode('cerebras')

src/ai-bundle/src/AiBundle.php

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
use Symfony\AI\Platform\Bridge\HuggingFace\PlatformFactory as HuggingFacePlatformFactory;
5858
use Symfony\AI\Platform\Bridge\LmStudio\PlatformFactory as LmStudioPlatformFactory;
5959
use Symfony\AI\Platform\Bridge\Mistral\PlatformFactory as MistralPlatformFactory;
60+
use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
6061
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory as OllamaPlatformFactory;
6162
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory;
6263
use Symfony\AI\Platform\Bridge\OpenRouter\PlatformFactory as OpenRouterPlatformFactory;
@@ -568,21 +569,31 @@ private function processPlatformConfig(string $type, array $platform, ContainerB
568569
}
569570

570571
if ('ollama' === $type) {
571-
$platformId = 'ai.platform.ollama';
572+
if (\array_key_exists('api_catalog', $platform)) {
573+
$catalogDefinition = (new Definition(OllamaApiCatalog::class))
574+
->setLazy(true)
575+
->setArguments([
576+
$platform['host_url'],
577+
new Reference('http_client'),
578+
]);
579+
580+
$container->setDefinition('ai.platform.model_catalog.ollama', $catalogDefinition);
581+
}
582+
572583
$definition = (new Definition(Platform::class))
573584
->setFactory(OllamaPlatformFactory::class.'::create')
574585
->setLazy(true)
575-
->addTag('proxy', ['interface' => PlatformInterface::class])
576586
->setArguments([
577587
$platform['host_url'],
578588
new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE),
579589
new Reference('ai.platform.model_catalog.ollama'),
580590
new Reference('ai.platform.contract.ollama'),
581591
new Reference('event_dispatcher'),
582592
])
593+
->addTag('proxy', ['interface' => PlatformInterface::class])
583594
->addTag('ai.platform', ['name' => 'ollama']);
584595

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

587598
return;
588599
}

src/ai-bundle/tests/DependencyInjection/AiBundleTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use Symfony\AI\AiBundle\AiBundle;
2424
use Symfony\AI\Chat\ChatInterface;
2525
use Symfony\AI\Chat\MessageStoreInterface;
26+
use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
2627
use Symfony\AI\Store\Document\Filter\TextContainsFilter;
2728
use Symfony\AI\Store\Document\Loader\InMemoryLoader;
2829
use Symfony\AI\Store\Document\Transformer\TextTrimTransformer;
@@ -583,6 +584,45 @@ public function testConfigurationWithUseAttributeAsKeyWorksWithoutNormalizeKeys(
583584
$this->assertTrue($container->hasDefinition('ai.store.mongodb.Production_DB-v3'));
584585
}
585586

587+
public function testOllamaCanBeCreatedWithCatalogFromApi()
588+
{
589+
$container = $this->buildContainer([
590+
'ai' => [
591+
'platform' => [
592+
'ollama' => [
593+
'api_catalog' => true,
594+
],
595+
],
596+
],
597+
]);
598+
599+
$this->assertTrue($container->hasDefinition('ai.platform.ollama'));
600+
$this->assertTrue($container->hasDefinition('ai.platform.model_catalog.ollama'));
601+
602+
$ollamaDefinition = $container->getDefinition('ai.platform.ollama');
603+
604+
$this->assertTrue($ollamaDefinition->isLazy());
605+
$this->assertCount(5, $ollamaDefinition->getArguments());
606+
$this->assertSame('http://127.0.0.1:11434', $ollamaDefinition->getArgument(0));
607+
$this->assertInstanceOf(Reference::class, $ollamaDefinition->getArgument(1));
608+
$this->assertSame('http_client', (string) $ollamaDefinition->getArgument(1));
609+
$this->assertInstanceOf(Reference::class, $ollamaDefinition->getArgument(2));
610+
$this->assertSame('ai.platform.model_catalog.ollama', (string) $ollamaDefinition->getArgument(2));
611+
$this->assertInstanceOf(Reference::class, $ollamaDefinition->getArgument(3));
612+
$this->assertSame('ai.platform.contract.ollama', (string) $ollamaDefinition->getArgument(3));
613+
$this->assertInstanceOf(Reference::class, $ollamaDefinition->getArgument(4));
614+
$this->assertSame('event_dispatcher', (string) $ollamaDefinition->getArgument(4));
615+
616+
$ollamaCatalogDefinition = $container->getDefinition('ai.platform.model_catalog.ollama');
617+
618+
$this->assertTrue($ollamaCatalogDefinition->isLazy());
619+
$this->assertSame(OllamaApiCatalog::class, $ollamaCatalogDefinition->getClass());
620+
$this->assertCount(2, $ollamaCatalogDefinition->getArguments());
621+
$this->assertSame('http://127.0.0.1:11434', $ollamaCatalogDefinition->getArgument(0));
622+
$this->assertInstanceOf(Reference::class, $ollamaCatalogDefinition->getArgument(1));
623+
$this->assertSame('http_client', (string) $ollamaCatalogDefinition->getArgument(1));
624+
}
625+
586626
/**
587627
* Tests that processor tags use the full agent ID (ai.agent.my_agent) instead of just the agent name (my_agent).
588628
* This regression test prevents issues where processors would not be correctly associated with their agents.

src/platform/src/Bridge/Ollama/ModelCatalog.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,9 @@ public function __construct(array $additionalModels = [])
218218
],
219219
];
220220

221-
$this->models = array_merge($defaultModels, $additionalModels);
221+
$this->models = [
222+
...$defaultModels,
223+
...$additionalModels,
224+
];
222225
}
223226
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Bridge\Ollama;
13+
14+
use Symfony\AI\Platform\Capability;
15+
use Symfony\AI\Platform\Exception\InvalidArgumentException;
16+
use Symfony\AI\Platform\ModelCatalog\FallbackModelCatalog;
17+
use Symfony\Contracts\HttpClient\HttpClientInterface;
18+
19+
/**
20+
* @author Guillaume Loulier <[email protected]>
21+
*/
22+
final class OllamaApiCatalog extends FallbackModelCatalog
23+
{
24+
public function __construct(
25+
private readonly string $host,
26+
private readonly HttpClientInterface $httpClient,
27+
) {
28+
parent::__construct();
29+
}
30+
31+
public function getModel(string $modelName): Ollama
32+
{
33+
$model = parent::getModel($modelName);
34+
35+
if (\array_key_exists($model->getName(), $this->models)) {
36+
$finalModel = $this->models[$model->getName()];
37+
38+
return new $finalModel['class'](
39+
$model->getName(),
40+
$finalModel['capabilities'],
41+
$model->getOptions(),
42+
);
43+
}
44+
45+
$response = $this->httpClient->request('POST', \sprintf('%s/api/show', $this->host), [
46+
'json' => [
47+
'model' => $model->getName(),
48+
],
49+
]);
50+
51+
$payload = $response->toArray();
52+
53+
if ([] === $payload['capabilities']) {
54+
throw new InvalidArgumentException('The model information could not be retrieved from the Ollama API. Your Ollama server might be too old. Try upgrade it.');
55+
}
56+
57+
$capabilities = array_map(
58+
static fn (string $capability): Capability => match ($capability) {
59+
'embedding' => Capability::EMBEDDINGS,
60+
'completion' => Capability::INPUT_TEXT,
61+
'tools' => Capability::TOOL_CALLING,
62+
'thinking' => Capability::THINKING,
63+
'vision' => Capability::INPUT_IMAGE,
64+
default => throw new InvalidArgumentException(\sprintf('The "%s" capability is not supported', $capability)),
65+
},
66+
$payload['capabilities'],
67+
);
68+
69+
$finalModel = new Ollama($model->getName(), $capabilities, $model->getOptions());
70+
71+
$this->models[$finalModel->getName()] = [
72+
'class' => Ollama::class,
73+
'capabilities' => $finalModel->getCapabilities(),
74+
];
75+
76+
return $finalModel;
77+
}
78+
}

src/platform/src/Bridge/Ollama/OllamaClient.php

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\AI\Platform\Bridge\Ollama;
1313

14+
use Symfony\AI\Platform\Capability;
1415
use Symfony\AI\Platform\Exception\InvalidArgumentException;
1516
use Symfony\AI\Platform\Model;
1617
use Symfony\AI\Platform\ModelClientInterface;
@@ -36,21 +37,9 @@ public function supports(Model $model): bool
3637

3738
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
3839
{
39-
$response = $this->httpClient->request('POST', \sprintf('%s/api/show', $this->hostUrl), [
40-
'json' => [
41-
'model' => $model->getName(),
42-
],
43-
]);
44-
45-
$capabilities = $response->toArray()['capabilities'] ?? null;
46-
47-
if (null === $capabilities) {
48-
throw new InvalidArgumentException('The model information could not be retrieved from the Ollama API. Your Ollama server might be too old. Try upgrade it.');
49-
}
50-
5140
return match (true) {
52-
\in_array('completion', $capabilities, true) => $this->doCompletionRequest($payload, $options),
53-
\in_array('embedding', $capabilities, true) => $this->doEmbeddingsRequest($model, $payload, $options),
41+
\in_array(Capability::INPUT_MESSAGES, $model->getCapabilities(), true) => $this->doCompletionRequest($payload, $options),
42+
\in_array(Capability::EMBEDDINGS, $model->getCapabilities(), true) => $this->doEmbeddingsRequest($model, $payload, $options),
5443
default => throw new InvalidArgumentException(\sprintf('Unsupported model "%s": "%s".', $model::class, $model->getName())),
5544
};
5645
}

src/platform/src/Capability.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,10 @@ enum Capability: string
4242
// VOICE
4343
case TEXT_TO_SPEECH = 'text-to-speech';
4444
case SPEECH_TO_TEXT = 'speech-to-text';
45+
46+
// EMBEDDINGS
47+
case EMBEDDINGS = 'embeddings';
48+
49+
// Thinking
50+
case THINKING = 'thinking';
4551
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Tests\Bridge\Ollama;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
16+
use Symfony\AI\Platform\Capability;
17+
use Symfony\Component\HttpClient\MockHttpClient;
18+
use Symfony\Component\HttpClient\Response\JsonMockResponse;
19+
20+
final class OllamaApiCatalogTest extends TestCase
21+
{
22+
public function testModelCatalogCanReturnModelFromApi()
23+
{
24+
$httpClient = new MockHttpClient([
25+
new JsonMockResponse([
26+
'capabilities' => ['completion'],
27+
]),
28+
]);
29+
30+
$modelCatalog = new OllamaApiCatalog('http://127.0.0.1:11434', $httpClient);
31+
32+
$model = $modelCatalog->getModel('foo');
33+
34+
$this->assertSame('foo', $model->getName());
35+
$this->assertSame([
36+
Capability::INPUT_TEXT,
37+
], $model->getCapabilities());
38+
$this->assertSame(1, $httpClient->getRequestsCount());
39+
}
40+
}

src/platform/tests/Bridge/Ollama/OllamaClientTest.php

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Symfony\AI\Platform\Bridge\Ollama\OllamaClient;
1717
use Symfony\AI\Platform\Bridge\Ollama\OllamaResultConverter;
1818
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory;
19+
use Symfony\AI\Platform\Capability;
1920
use Symfony\AI\Platform\Model;
2021
use Symfony\AI\Platform\Result\RawHttpResult;
2122
use Symfony\AI\Platform\Result\StreamResult;
@@ -36,9 +37,6 @@ public function testSupportsModel()
3637
public function testOutputStructureIsSupported()
3738
{
3839
$httpClient = new MockHttpClient([
39-
new JsonMockResponse([
40-
'capabilities' => ['completion', 'tools'],
41-
]),
4240
new JsonMockResponse([
4341
'model' => 'foo',
4442
'response' => [
@@ -50,7 +48,10 @@ public function testOutputStructureIsSupported()
5048
], 'http://127.0.0.1:1234');
5149

5250
$client = new OllamaClient($httpClient, 'http://127.0.0.1:1234');
53-
$response = $client->request(new Ollama('llama3.2'), [
51+
$response = $client->request(new Ollama('llama3.2', [
52+
Capability::INPUT_MESSAGES,
53+
Capability::TOOL_CALLING,
54+
]), [
5455
'messages' => [
5556
[
5657
'role' => 'user',
@@ -77,7 +78,7 @@ public function testOutputStructureIsSupported()
7778
],
7879
]);
7980

80-
$this->assertSame(2, $httpClient->getRequestsCount());
81+
$this->assertSame(1, $httpClient->getRequestsCount());
8182
$this->assertSame([
8283
'model' => 'foo',
8384
'response' => [
@@ -91,9 +92,6 @@ public function testOutputStructureIsSupported()
9192
public function testStreamingIsSupported()
9293
{
9394
$httpClient = new MockHttpClient([
94-
new JsonMockResponse([
95-
'capabilities' => ['completion'],
96-
]),
9795
new MockResponse('data: '.json_encode([
9896
'model' => 'llama3.2',
9997
'created_at' => '2025-08-23T10:00:00Z',
@@ -123,7 +121,7 @@ public function testStreamingIsSupported()
123121

124122
$this->assertInstanceOf(StreamResult::class, $result);
125123
$this->assertInstanceOf(\Generator::class, $result->getContent());
126-
$this->assertSame(2, $httpClient->getRequestsCount());
124+
$this->assertSame(1, $httpClient->getRequestsCount());
127125
}
128126

129127
public function testStreamingConverterWithDirectResponse()

0 commit comments

Comments
 (0)