Skip to content

Commit 46865c9

Browse files
committed
ref(platform): OllamaCatalog
1 parent 1e69969 commit 46865c9

File tree

11 files changed

+257
-27
lines changed

11 files changed

+257
-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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
13+
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory;
14+
use Symfony\AI\Platform\Message\Message;
15+
use Symfony\AI\Platform\Message\MessageBag;
16+
17+
require_once dirname(__DIR__).'/bootstrap.php';
18+
19+
$platform = PlatformFactory::create(env('OLLAMA_HOST_URL'), http_client(), new OllamaApiCatalog(
20+
env('OLLAMA_HOST_URL'),
21+
http_client(),
22+
));
23+
24+
$messages = new MessageBag(
25+
Message::forSystem('You are a helpful assistant.'),
26+
Message::ofUser('Tina has one brother and one sister. How many sisters do Tina\'s siblings have?'),
27+
);
28+
29+
try {
30+
$result = $platform->invoke(env('OLLAMA_LLM'), $messages);
31+
echo $result->asText().\PHP_EOL;
32+
} catch (InvalidArgumentException $e) {
33+
echo $e->getMessage()."\nMaybe use a different model?\n";
34+
}

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
@@ -55,6 +55,7 @@
5555
use Symfony\AI\Platform\Bridge\HuggingFace\PlatformFactory as HuggingFacePlatformFactory;
5656
use Symfony\AI\Platform\Bridge\LmStudio\PlatformFactory as LmStudioPlatformFactory;
5757
use Symfony\AI\Platform\Bridge\Mistral\PlatformFactory as MistralPlatformFactory;
58+
use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
5859
use Symfony\AI\Platform\Bridge\Ollama\PlatformFactory as OllamaPlatformFactory;
5960
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory;
6061
use Symfony\AI\Platform\Bridge\OpenRouter\PlatformFactory as OpenRouterPlatformFactory;
@@ -564,21 +565,31 @@ private function processPlatformConfig(string $type, array $platform, ContainerB
564565
}
565566

566567
if ('ollama' === $type) {
567-
$platformId = 'ai.platform.ollama';
568+
if (\array_key_exists('api_catalog', $platform)) {
569+
$catalogDefinition = (new Definition(OllamaApiCatalog::class))
570+
->setLazy(true)
571+
->setArguments([
572+
$platform['host_url'],
573+
new Reference('http_client'),
574+
]);
575+
576+
$container->setDefinition('ai.platform.model_catalog.ollama', $catalogDefinition);
577+
}
578+
568579
$definition = (new Definition(Platform::class))
569580
->setFactory(OllamaPlatformFactory::class.'::create')
570581
->setLazy(true)
571-
->addTag('proxy', ['interface' => PlatformInterface::class])
572582
->setArguments([
573583
$platform['host_url'],
574584
new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE),
575585
new Reference('ai.platform.model_catalog.ollama'),
576586
new Reference('ai.platform.contract.ollama'),
577587
new Reference('event_dispatcher'),
578588
])
589+
->addTag('proxy', ['interface' => PlatformInterface::class])
579590
->addTag('ai.platform', ['name' => 'ollama']);
580591

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

583594
return;
584595
}

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;
@@ -579,6 +580,45 @@ public function testConfigurationWithUseAttributeAsKeyWorksWithoutNormalizeKeys(
579580
$this->assertTrue($container->hasDefinition('ai.store.mongodb.Production_DB-v3'));
580581
}
581582

583+
public function testOllamaCanBeCreatedWithCatalogFromApi()
584+
{
585+
$container = $this->buildContainer([
586+
'ai' => [
587+
'platform' => [
588+
'ollama' => [
589+
'use_api_as_catalog' => true,
590+
],
591+
],
592+
],
593+
]);
594+
595+
$this->assertTrue($container->hasDefinition('ai.platform.ollama'));
596+
$this->assertTrue($container->hasDefinition('ai.platform.model_catalog.ollama'));
597+
598+
$ollamaDefinition = $container->getDefinition('ai.platform.ollama');
599+
600+
$this->assertTrue($ollamaDefinition->isLazy());
601+
$this->assertCount(4, $ollamaDefinition->getArguments());
602+
$this->assertSame('http://127.0.0.1:11434', $ollamaDefinition->getArgument(0));
603+
$this->assertInstanceOf(Reference::class, $ollamaDefinition->getArgument(1));
604+
$this->assertSame('http_client', (string) $ollamaDefinition->getArgument(1));
605+
$this->assertInstanceOf(Reference::class, $ollamaDefinition->getArgument(2));
606+
$this->assertSame('ai.platform.model_catalog.ollama', (string) $ollamaDefinition->getArgument(2));
607+
$this->assertInstanceOf(Reference::class, $ollamaDefinition->getArgument(3));
608+
$this->assertSame('ai.platform.contract.ollama', (string) $ollamaDefinition->getArgument(3));
609+
610+
$ollamaCatalogDefinition = $container->getDefinition('ai.platform.model_catalog.ollama');
611+
612+
$this->assertTrue($ollamaCatalogDefinition->isLazy());
613+
$this->assertSame(OllamaApiCatalog::class, $ollamaCatalogDefinition->getClass());
614+
$this->assertCount(3, $ollamaCatalogDefinition->getArguments());
615+
$this->assertSame('http://127.0.0.1:11434', $ollamaCatalogDefinition->getArgument(0));
616+
$this->assertInstanceOf(Reference::class, $ollamaCatalogDefinition->getArgument(1));
617+
$this->assertSame('http_client', (string) $ollamaCatalogDefinition->getArgument(1));
618+
$this->assertInstanceOf(Reference::class, $ollamaCatalogDefinition->getArgument(2));
619+
$this->assertSame('.inner', (string) $ollamaCatalogDefinition->getArgument(2));
620+
}
621+
582622
/**
583623
* Tests that processor tags use the full agent ID (ai.agent.my_agent) instead of just the agent name (my_agent).
584624
* 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;
@@ -35,21 +36,9 @@ public function supports(Model $model): bool
3536

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

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+
}

0 commit comments

Comments
 (0)