Skip to content

Commit 3adacba

Browse files
committed
ref(platform): OllamaCatalog
1 parent 1e69969 commit 3adacba

File tree

8 files changed

+191
-25
lines changed

8 files changed

+191
-25
lines changed

src/ai-bundle/config/options.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@
160160
->defaultValue('http_client')
161161
->info('Service ID of the HTTP client to use')
162162
->end()
163+
->booleanNode('use_api_as_catalog')->end()
163164
->end()
164165
->end()
165166
->arrayNode('cerebras')

src/ai-bundle/src/AiBundle.php

Lines changed: 15 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,32 @@ private function processPlatformConfig(string $type, array $platform, ContainerB
564565
}
565566

566567
if ('ollama' === $type) {
567-
$platformId = 'ai.platform.ollama';
568+
if (\array_key_exists('use_api_as_catalog', $platform)) {
569+
$catalogDefinition = (new Definition(OllamaApiCatalog::class))
570+
->setDecoratedService('ai.platform.model_catalog.ollama')
571+
->setArguments([
572+
$platform['host_url'],
573+
new Reference('http_client'),
574+
new Reference('.inner'),
575+
]);
576+
577+
$container->setDefinition('ai.platform.model_catalog.ollama', $catalogDefinition);
578+
}
579+
568580
$definition = (new Definition(Platform::class))
569581
->setFactory(OllamaPlatformFactory::class.'::create')
570582
->setLazy(true)
571-
->addTag('proxy', ['interface' => PlatformInterface::class])
572583
->setArguments([
573584
$platform['host_url'],
574585
new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE),
575586
new Reference('ai.platform.model_catalog.ollama'),
576587
new Reference('ai.platform.contract.ollama'),
577588
new Reference('event_dispatcher'),
578589
])
590+
->addTag('proxy', ['interface' => PlatformInterface::class])
579591
->addTag('ai.platform', ['name' => 'ollama']);
580592

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

583595
return;
584596
}

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->assertCount(4, $ollamaDefinition->getArguments());
601+
$this->assertSame('http://127.0.0.1:11434', $ollamaDefinition->getArgument(0));
602+
$this->assertInstanceOf(Reference::class, $ollamaDefinition->getArgument(1));
603+
$this->assertSame('http_client', (string) $ollamaDefinition->getArgument(1));
604+
$this->assertInstanceOf(Reference::class, $ollamaDefinition->getArgument(2));
605+
$this->assertSame('ai.platform.model_catalog.ollama', (string) $ollamaDefinition->getArgument(2));
606+
$this->assertInstanceOf(Reference::class, $ollamaDefinition->getArgument(3));
607+
$this->assertSame('ai.platform.contract.ollama', (string) $ollamaDefinition->getArgument(3));
608+
609+
$this->assertTrue($ollamaDefinition->isLazy());
610+
611+
$ollamaCatalogDefinition = $container->getDefinition('ai.platform.model_catalog.ollama');
612+
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.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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\Model;
17+
use Symfony\AI\Platform\ModelCatalog\FallbackModelCatalog;
18+
use Symfony\Contracts\HttpClient\HttpClientInterface;
19+
20+
/**
21+
* @author Guillaume Loulier <[email protected]>
22+
*/
23+
final class OllamaApiCatalog extends FallbackModelCatalog
24+
{
25+
public function __construct(
26+
private readonly string $host,
27+
private readonly HttpClientInterface $httpClient,
28+
private readonly ModelCatalog $inner,
29+
) {
30+
parent::__construct();
31+
}
32+
33+
public function getModel(string $modelName): Model
34+
{
35+
$model = parent::getModel($modelName);
36+
37+
if (\array_key_exists($model->getName(), $this->models)) {
38+
$finalModel = $this->models[$model->getName()];
39+
40+
return new $finalModel['class'](
41+
$model->getName(),
42+
$finalModel['capabilities'],
43+
$model->getOptions(),
44+
);
45+
}
46+
47+
try {
48+
$response = $this->httpClient->request('POST', \sprintf('%s/api/show', $this->host), [
49+
'json' => [
50+
'model' => $model->getName(),
51+
],
52+
]);
53+
54+
$payload = $response->toArray();
55+
56+
if ([] === $payload['capabilities'] ?? []) {
57+
throw new InvalidArgumentException('The model information could not be retrieved from the Ollama API. Your Ollama server might be too old. Try upgrade it.');
58+
}
59+
60+
$capabilities = array_map(
61+
static fn (string $capability): Capability => match ($capability) {
62+
'embeddings' => Capability::EMBEDDINGS,
63+
'completion' => Capability::INPUT_TEXT,
64+
'tools' => Capability::TOOLS,
65+
'vision' => Capability::INPUT_IMAGE,
66+
default => throw new InvalidArgumentException(\sprintf('The "%s" capability is not supported', $capability)),
67+
},
68+
$payload['capabilities'],
69+
);
70+
71+
$finalModel = new Ollama($model->getName(), $capabilities, $model->getOptions());
72+
73+
$this->models[$finalModel->getName()] = [
74+
'class' => Ollama::class,
75+
'capabilities' => $finalModel->getCapabilities(),
76+
];
77+
78+
return $finalModel;
79+
} catch (\Throwable) {
80+
return $this->inner->getModel($modelName);
81+
}
82+
}
83+
}

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::COMPLETION, $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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,16 @@ enum Capability: string
3535
case OUTPUT_STREAMING = 'output-streaming';
3636
case OUTPUT_STRUCTURED = 'output-structured';
3737
case OUTPUT_TEXT = 'output-text';
38+
case COMPLETION = 'completion';
3839

3940
// FUNCTIONALITY
4041
case TOOL_CALLING = 'tool-calling';
42+
case TOOLS = 'tools';
4143

4244
// VOICE
4345
case TEXT_TO_SPEECH = 'text-to-speech';
4446
case SPEECH_TO_TEXT = 'speech-to-text';
47+
48+
// EMBEDDINGS
49+
case EMBEDDINGS = 'embeddings';
4550
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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\ModelCatalog;
16+
use Symfony\AI\Platform\Bridge\Ollama\Ollama;
17+
use Symfony\AI\Platform\Bridge\Ollama\OllamaApiCatalog;
18+
use Symfony\Component\HttpClient\MockHttpClient;
19+
use Symfony\Component\HttpClient\Response\JsonMockResponse;
20+
21+
final class OllamaApiCatalogTest extends TestCase
22+
{
23+
public function testModelCatalogCanReturnModelFromApi()
24+
{
25+
$httpClient = new MockHttpClient([
26+
new JsonMockResponse([
27+
'capabilities' => ['completion'],
28+
]),
29+
]);
30+
31+
$modelCatalog = new OllamaApiCatalog('http://127.0.0.1:11434', $httpClient, new ModelCatalog());
32+
33+
$model = $modelCatalog->getModel('foo');
34+
35+
$this->assertInstanceOf(Ollama::class, $model);
36+
$this->assertSame(1, $httpClient->getRequestsCount());
37+
}
38+
}

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

Lines changed: 6 additions & 8 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::COMPLETION,
53+
Capability::TOOLS,
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',

0 commit comments

Comments
 (0)