Skip to content

Commit 60c72e4

Browse files
authored
Merge pull request #34 from CommunityToolkit/aaronpowell/issue32
Supporting multiple models in Ollama
2 parents 0570ad3 + 4c4c648 commit 60c72e4

File tree

7 files changed

+249
-102
lines changed

7 files changed

+249
-102
lines changed

docs/integrations/hosting-ollama.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,20 @@ Use the static `AddOllama` method to add this container component to the applica
1313
```csharp
1414
// The distributed application builder is created here
1515
16-
var ollama = builder.AddOllama("ollama");
16+
var ollama = builder.AddOllama("ollama").AddModel("llama3");
1717

1818
// The builder is used to build and run the app somewhere down here
1919
```
2020

2121
### Configuration
2222

23-
The AddOllama method has optional arguments to set the `name`, `port` and `modelName`.
23+
The AddOllama method has optional arguments to set the `name` and `port`.
2424
The `name` is what gets displayed in the Aspire orchestration app against this component.
2525
The `port` is provided randomly by Aspire. If for whatever reason you need a fixed port, you can set that here.
26-
The `modelName` specifies what LLM to pull when it starts up. The default is `llama3`. You can also set this to null to prevent any models being pulled on startup - leaving you with a plain Ollama container to work with.
2726

2827
## Downloading the LLM
2928

30-
When the Ollama container for this component first spins up, this component will download the LLM (llama3 unless otherwise specified).
29+
When the Ollama container for this component first spins up, this component will download the LLM(s).
3130
The progress of this download will be displayed in the State column for this component on the Aspire orchestration app.
3231
Important: Keep the Aspire orchestration app open until the download is complete, otherwise the download will be cancelled.
3332
In the spirit of productivity, we recommend kicking off this process before heading for lunch.
@@ -45,8 +44,7 @@ Within that component (e.g. a web app), you can fetch the Ollama connection stri
4544
Note that if you changed the name of the Ollama component via the `name` argument, then you'll need to use that here when specifying which connection string to get.
4645

4746
```csharp
48-
var connectionString = builder.Configuration.GetConnectionString("Ollama");
47+
var connectionString = builder.Configuration.GetConnectionString("ollama");
4948
```
5049

5150
You can then call any of the Ollama endpoints through this connection string. We recommend using the [OllamaSharp](https://www.nuget.org/packages/OllamaSharp) client to do this.
52-

examples/ollama/Aspire.CommunityToolkit.Hosting.Ollama.AppHost/Program.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
var builder = DistributedApplication.CreateBuilder(args);
22

3-
var ollama = builder.AddOllama("ollama", modelName: "phi3");
3+
var ollama = builder.AddOllama("ollama", port: null)
4+
.AddModel("phi3")
5+
.WithDefaultModel("phi3");
46

57
builder.AddProject<Projects.Aspire_CommunityToolkit_Hosting_Ollama_Web>("webfrontend")
68
.WithExternalHttpEndpoints()

src/Aspire.CommunityToolkit.Hosting.Ollama/OllamaResource.cs

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,25 @@
88
/// </remarks>
99
/// <param name="name">The name for the resource.</param>
1010
/// <param name="modelName">The LLM to download on initial startup.</param>
11-
public class OllamaResource(string name, string modelName) : ContainerResource(name), IResourceWithConnectionString
11+
public class OllamaResource(string name) : ContainerResource(name), IResourceWithConnectionString
1212
{
1313
internal const string OllamaEndpointName = "ollama";
1414

15+
private readonly List<string> _models = [];
16+
17+
private string? _defaultModel = null;
18+
1519
private EndpointReference? _endpointReference;
1620

17-
public string ModelName { get; internal set; } = modelName;
21+
/// <summary>
22+
/// Adds a model to the list of models to download on initial startup.
23+
/// </summary>
24+
public IReadOnlyList<string> Models => _models;
25+
26+
/// <summary>
27+
/// The default model to be configured on the Ollama server.
28+
/// </summary>
29+
public string? DefaultModel => _defaultModel;
1830

1931
/// <summary>
2032
/// Gets the endpoint for the Ollama server.
@@ -28,4 +40,35 @@ public class OllamaResource(string name, string modelName) : ContainerResource(n
2840
ReferenceExpression.Create(
2941
$"http://{Endpoint.Property(EndpointProperty.Host)}:{Endpoint.Property(EndpointProperty.Port)}"
3042
);
31-
}
43+
44+
/// <summary>
45+
/// Adds a model to the list of models to download on initial startup.
46+
/// </summary>
47+
/// <param name="modelName">The name of the model</param>
48+
public void AddModel(string modelName)
49+
{
50+
ArgumentNullException.ThrowIfNullOrEmpty(modelName, nameof(modelName));
51+
if (!_models.Contains(modelName))
52+
{
53+
_models.Add(modelName);
54+
}
55+
}
56+
57+
/// <summary>
58+
/// Sets the default model to be configured on the Ollama server.
59+
/// </summary>
60+
/// <param name="modelName">The name of the model.</param>
61+
/// <remarks>
62+
/// If the model does not exist in the list of models, it will be added.
63+
/// </remarks>
64+
public void SetDefaultModel(string modelName)
65+
{
66+
ArgumentNullException.ThrowIfNullOrEmpty(modelName, nameof(modelName));
67+
_defaultModel = modelName;
68+
69+
if (!_models.Contains(modelName))
70+
{
71+
AddModel(modelName);
72+
}
73+
}
74+
}

src/Aspire.CommunityToolkit.Hosting.Ollama/OllamaResourceBuilderExtensions.cs

Lines changed: 79 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,41 +10,86 @@ namespace Aspire.Hosting;
1010
/// </summary>
1111
public static class OllamaResourceBuilderExtensions
1212
{
13-
/// <summary>
14-
/// Adds the Ollama container to the application model.
15-
/// </summary>
16-
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
17-
/// <param name="name">The name of the resource. This name will be used as the connection string name when referenced in a dependency.</param>
18-
/// <param name="port">An optional fixed port to bind to the Ollama container. This will be provided randomly by Aspire if not set.</param>
19-
/// <param name="modelName">The name of the LLM to download on initial startup. llama3 by default. This can be set to null to not download any models.</param>
20-
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
21-
public static IResourceBuilder<OllamaResource> AddOllama(this IDistributedApplicationBuilder builder,
22-
string name = "Ollama", int? port = null, string modelName = "llama3")
23-
{
24-
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
25-
ArgumentNullException.ThrowIfNull(name, nameof(name));
26-
27-
builder.Services.TryAddLifecycleHook<OllamaResourceLifecycleHook>();
28-
var resource = new OllamaResource(name, modelName);
29-
return builder.AddResource(resource)
30-
.WithAnnotation(new ContainerImageAnnotation { Image = OllamaContainerImageTags.Image, Tag = OllamaContainerImageTags.Tag, Registry = OllamaContainerImageTags.Registry })
31-
.WithHttpEndpoint(port: port, targetPort: 11434, name: OllamaResource.OllamaEndpointName)
32-
.ExcludeFromManifest();
33-
}
34-
35-
/// <summary>
36-
/// Adds a data volume to the Ollama container.
37-
/// </summary>
38-
/// <param name="builder">The <see cref="IResourceBuilder{T}"/>.</param>
39-
/// <param name="name">The name of the volume. Defaults to an auto-generated name based on the application and resource names.</param>
40-
/// <param name="isReadOnly">A flag that indicates if this is a read-only volume.</param>
41-
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
42-
public static IResourceBuilder<OllamaResource> WithDataVolume(this IResourceBuilder<OllamaResource> builder, string? name = null, bool isReadOnly = false)
43-
{
44-
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
13+
/// <summary>
14+
/// Adds the Ollama container to the application model.
15+
/// </summary>
16+
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
17+
/// <param name="name">The name of the resource. This name will be used as the connection string name when referenced in a dependency.</param>
18+
/// <param name="port">An optional fixed port to bind to the Ollama container. This will be provided randomly by Aspire if not set.</param>
19+
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
20+
public static IResourceBuilder<OllamaResource> AddOllama(this IDistributedApplicationBuilder builder, string name, int? port = null)
21+
{
22+
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
23+
ArgumentNullException.ThrowIfNull(name, nameof(name));
24+
25+
builder.Services.TryAddLifecycleHook<OllamaResourceLifecycleHook>();
26+
var resource = new OllamaResource(name);
27+
return builder.AddResource(resource)
28+
.WithAnnotation(new ContainerImageAnnotation { Image = OllamaContainerImageTags.Image, Tag = OllamaContainerImageTags.Tag, Registry = OllamaContainerImageTags.Registry })
29+
.WithHttpEndpoint(port: port, targetPort: 11434, name: OllamaResource.OllamaEndpointName)
30+
.ExcludeFromManifest();
31+
}
32+
33+
/// <summary>
34+
/// Adds the Ollama container to the application model.
35+
/// </summary>
36+
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
37+
/// <param name="name">The name of the resource. This name will be used as the connection string name when referenced in a dependency.</param>
38+
/// <param name="port">An optional fixed port to bind to the Ollama container. This will be provided randomly by Aspire if not set.</param>
39+
/// <param name="modelName">The name of the LLM to download on initial startup. llama3 by default. This can be set to null to not download any models.</param>
40+
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
41+
/// <remarks>This is to maintain compatibility with the Raygun.Aspire.Hosting.Ollama package and will be removed in the next major release.</remarks>
42+
[Obsolete("Use AddOllama without a model name, and then the AddModel extension method to add models.")]
43+
public static IResourceBuilder<OllamaResource> AddOllama(this IDistributedApplicationBuilder builder,
44+
string name = "Ollama", int? port = null, string modelName = "llama3")
45+
{
46+
return builder.AddOllama(name, port)
47+
.AddModel(modelName);
48+
}
49+
50+
/// <summary>
51+
/// Adds a data volume to the Ollama container.
52+
/// </summary>
53+
/// <param name="builder">The <see cref="IResourceBuilder{T}"/>.</param>
54+
/// <param name="name">The name of the volume. Defaults to an auto-generated name based on the application and resource names.</param>
55+
/// <param name="isReadOnly">A flag that indicates if this is a read-only volume.</param>
56+
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
57+
public static IResourceBuilder<OllamaResource> WithDataVolume(this IResourceBuilder<OllamaResource> builder, string? name = null, bool isReadOnly = false)
58+
{
59+
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
4560

4661
#pragma warning disable CTASPIRE001
47-
return builder.WithVolume(name ?? VolumeNameGenerator.CreateVolumeName(builder, "ollama"), "/root/.ollama", isReadOnly);
62+
return builder.WithVolume(name ?? VolumeNameGenerator.CreateVolumeName(builder, "ollama"), "/root/.ollama", isReadOnly);
4863
#pragma warning restore CTASPIRE001
49-
}
64+
}
65+
66+
/// <summary>
67+
/// Adds a model to the Ollama container.
68+
/// </summary>
69+
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
70+
/// <param name="modelName">The name of the LLM to download on initial startup.</param>
71+
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
72+
public static IResourceBuilder<OllamaResource> AddModel(this IResourceBuilder<OllamaResource> builder, string modelName)
73+
{
74+
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
75+
ArgumentException.ThrowIfNullOrWhiteSpace(modelName, nameof(modelName));
76+
77+
builder.Resource.AddModel(modelName);
78+
return builder;
79+
}
80+
81+
/// <summary>
82+
/// Sets the default model to be configured on the Ollama server.
83+
/// </summary>
84+
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
85+
/// <param name="modelName">The name of the model.</param>
86+
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
87+
public static IResourceBuilder<OllamaResource> WithDefaultModel(this IResourceBuilder<OllamaResource> builder, string modelName)
88+
{
89+
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
90+
ArgumentException.ThrowIfNullOrWhiteSpace(modelName, nameof(modelName));
91+
92+
builder.Resource.SetDefaultModel(modelName);
93+
return builder;
94+
}
5095
}

src/Aspire.CommunityToolkit.Hosting.Ollama/OllamaResourceLifecycleHook.cs

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -33,52 +33,50 @@ public Task AfterResourcesCreatedAsync(DistributedApplicationModel appModel, Can
3333

3434
private void DownloadModel(OllamaResource resource, CancellationToken cancellationToken)
3535
{
36-
if (string.IsNullOrWhiteSpace(resource.ModelName))
37-
{
38-
return;
39-
}
40-
4136
var logger = loggerService.GetLogger(resource);
4237

4338
_ = Task.Run(async () =>
4439
{
45-
try
40+
foreach (string model in resource.Models)
4641
{
47-
var connectionString = await resource.ConnectionStringExpression.GetValueAsync(cancellationToken).ConfigureAwait(false);
48-
49-
if (string.IsNullOrWhiteSpace(connectionString))
42+
try
5043
{
51-
await _notificationService.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot("No connection string", KnownResourceStateStyles.Error) });
52-
return;
53-
}
44+
var connectionString = await resource.ConnectionStringExpression.GetValueAsync(cancellationToken).ConfigureAwait(false);
5445

55-
var ollamaClient = new OllamaApiClient(new Uri(connectionString));
56-
var model = resource.ModelName;
46+
if (string.IsNullOrWhiteSpace(connectionString))
47+
{
48+
await _notificationService.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot("No connection string", KnownResourceStateStyles.Error) });
49+
return;
50+
}
5751

58-
await _notificationService.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot("Checking model", KnownResourceStateStyles.Info) });
59-
var hasModel = await HasModelAsync(ollamaClient, model, cancellationToken);
52+
var ollamaClient = new OllamaApiClient(new Uri(connectionString));
6053

61-
if (!hasModel)
62-
{
63-
logger.LogInformation("{TimeStamp}: [{Model}] needs to be downloaded for {ResourceName}",
64-
DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture),
65-
resource.ModelName,
66-
resource.Name);
67-
await PullModel(resource, ollamaClient, model, logger, cancellationToken);
54+
await _notificationService.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot($"Checking {model}", KnownResourceStateStyles.Info) });
55+
var hasModel = await HasModelAsync(ollamaClient, model, cancellationToken);
56+
57+
if (!hasModel)
58+
{
59+
logger.LogInformation("{TimeStamp}: [{Model}] needs to be downloaded for {ResourceName}",
60+
DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture),
61+
model,
62+
resource.Name);
63+
await PullModel(resource, ollamaClient, model, logger, cancellationToken);
64+
}
65+
else
66+
{
67+
logger.LogInformation("{TimeStamp}: [{Model}] already exists for {ResourceName}",
68+
DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture),
69+
model,
70+
resource.Name);
71+
}
72+
73+
await _notificationService.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success) });
6874
}
69-
else
75+
catch (Exception ex)
7076
{
71-
logger.LogInformation("{TimeStamp}: [{Model}] already exists for {ResourceName}",
72-
DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture),
73-
resource.ModelName,
74-
resource.Name);
77+
await _notificationService.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot(ex.Message, KnownResourceStateStyles.Error) });
78+
break;
7579
}
76-
77-
await _notificationService.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success) });
78-
}
79-
catch (Exception ex)
80-
{
81-
await _notificationService.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot(ex.Message, KnownResourceStateStyles.Error) });
8280
}
8381

8482
}, cancellationToken).ConfigureAwait(false);
@@ -110,7 +108,7 @@ private async Task PullModel(OllamaResource resource, OllamaApiClient ollamaClie
110108
logger.LogInformation("{TimeStamp}: Pulling ollama model {Model}...",
111109
DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture),
112110
model);
113-
await _notificationService.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot("Downloading model", KnownResourceStateStyles.Info) });
111+
await _notificationService.PublishUpdateAsync(resource, state => state with { State = new ResourceStateSnapshot($"Downloading {model}", KnownResourceStateStyles.Info) });
114112

115113
long percentage = 0;
116114

@@ -128,7 +126,7 @@ private async Task PullModel(OllamaResource resource, OllamaApiClient ollamaClie
128126
{
129127
percentage = newPercentage;
130128

131-
var percentageState = percentage == 0 ? "Downloading model" : $"Downloading model {percentage} percent";
129+
var percentageState = $"Downloading {model}{(percentage > 0 ? $" {percentage} percent" : "")}";
132130
await _notificationService.PublishUpdateAsync(resource,
133131
state => state with
134132
{

tests/Aspire.CommunityToolkit.Hosting.Java.Tests/JavaHostingComponentTests.cs

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,12 @@ namespace Aspire.CommunityToolkit.Hosting.Java.Tests;
77
#pragma warning disable CTASPIRE001
88
public class JavaHostingComponentTests(AspireIntegrationTestFixture<Projects.Aspire_CommunityToolkit_Hosting_Java_AppHost> fixture) : IClassFixture<AspireIntegrationTestFixture<Projects.Aspire_CommunityToolkit_Hosting_Java_AppHost>>
99
{
10-
[ConditionalFact]
10+
[ConditionalTheory]
1111
[OSSkipCondition(OperatingSystems.Windows)]
12-
public async Task ContainerAppResourceWillRespondWithOk()
12+
[InlineData("containerapp")]
13+
[InlineData("executableapp")]
14+
public async Task AppResourceWillRespondWithOk(string resourceName)
1315
{
14-
var resourceName = "containerapp";
15-
var httpClient = fixture.CreateHttpClient(resourceName);
16-
17-
await fixture.App.WaitForTextAsync("Started SpringMavenApplication", resourceName).WaitAsync(TimeSpan.FromMinutes(5));
18-
19-
var response = await httpClient.GetAsync("/");
20-
21-
response.StatusCode.Should().Be(HttpStatusCode.OK);
22-
}
23-
24-
[ConditionalFact]
25-
[OSSkipCondition(OperatingSystems.Windows)]
26-
public async Task ExecutableAppResourceWillRespondWithOk()
27-
{
28-
var resourceName = "executableapp";
2916
var httpClient = fixture.CreateHttpClient(resourceName);
3017

3118
await fixture.App.WaitForTextAsync("Started SpringMavenApplication", resourceName).WaitAsync(TimeSpan.FromMinutes(5));

0 commit comments

Comments
 (0)