Skip to content

Commit ce71d6f

Browse files
authored
Merge pull request #21 from razeone/feat/ollama-backend
feat(backends): add Ollama as a chat client backend
2 parents 4ec01a0 + a0f28a6 commit ce71d6f

14 files changed

Lines changed: 354 additions & 4 deletions

File tree

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<PackageVersion Include="Azure.AI.Inference" Version="1.0.0-beta.5" />
1414
<PackageVersion Include="OpenAI" Version="2.10.0" />
1515
<PackageVersion Include="Anthropic.SDK" Version="5.10.0" />
16+
<PackageVersion Include="OllamaSharp" Version="5.4.25" />
1617
<!-- MCP -->
1718
<PackageVersion Include="ModelContextProtocol" Version="0.2.0-preview.3" />
1819
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="0.2.0-preview.3" />

src/CloudEngAgent.Api/appsettings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
"azure-foundry": { "Endpoint": "", "Model": "", "ApiKeyRef": "foundry-key" },
3939
"anthropic": { "Model": "claude-opus-4-7", "ApiKeyRef": "anthropic-key" },
4040
"github-models": { "Model": "gpt-4o", "ApiKeyRef": "github-pat" },
41-
"openai": { "Model": "gpt-4o", "ApiKeyRef": "openai-key" }
41+
"openai": { "Model": "gpt-4o", "ApiKeyRef": "openai-key" },
42+
"ollama": { "Endpoint": "", "Model": "", "ApiKeyRef": "" }
4243
},
4344
"Mcp": {
4445
"Servers": [

src/CloudEngAgent.Domain/Backends/BackendId.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ public sealed record BackendId
1111
public static BackendId Anthropic { get; } = new("anthropic");
1212
public static BackendId GitHubModels { get; } = new("github-models");
1313
public static BackendId OpenAi { get; } = new("openai");
14+
public static BackendId Ollama { get; } = new("ollama");
1415

1516
public static IReadOnlyList<BackendId> All { get; } = new[]
1617
{
17-
AzureOpenAi, AzureFoundry, Anthropic, GitHubModels, OpenAi
18+
AzureOpenAi, AzureFoundry, Anthropic, GitHubModels, OpenAi, Ollama
1819
};
1920

2021
public static BackendId Parse(string value)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using System.Net.Http.Headers;
2+
using CloudEngAgent.Application.Abstractions;
3+
using CloudEngAgent.Infrastructure.Backends.Options;
4+
using Microsoft.Extensions.AI;
5+
using OllamaSharp;
6+
7+
namespace CloudEngAgent.Infrastructure.Backends.Adapters;
8+
9+
/// <summary>
10+
/// Static factory for creating an <see cref="IChatClient"/> backed by an Ollama server.
11+
/// <para>
12+
/// <see cref="OllamaApiClient"/> from OllamaSharp implements <see cref="IChatClient"/>
13+
/// natively. When <see cref="OllamaOptions.ApiKeyRef"/> is set, requests are sent through
14+
/// an <see cref="HttpClient"/> with a <c>Bearer</c> <see cref="AuthenticationHeaderValue"/>
15+
/// for proxied/secured deployments; otherwise the server is contacted unauthenticated.
16+
/// </para>
17+
/// </summary>
18+
internal static class OllamaChatClientAdapter
19+
{
20+
public static IChatClient Create(OllamaOptions opts, IBackendSecretResolver secrets)
21+
{
22+
if (string.IsNullOrEmpty(opts.Endpoint))
23+
throw new InvalidOperationException(
24+
"Backend 'ollama' is not configured: missing 'Endpoint'.");
25+
26+
if (string.IsNullOrEmpty(opts.Model))
27+
throw new InvalidOperationException(
28+
"Backend 'ollama' is not configured: missing 'Model'.");
29+
30+
var endpoint = new Uri(opts.Endpoint);
31+
32+
if (string.IsNullOrEmpty(opts.ApiKeyRef))
33+
{
34+
return new OllamaApiClient(endpoint, opts.Model);
35+
}
36+
37+
var apiKey = secrets.ResolveAsync(opts.ApiKeyRef, CancellationToken.None)
38+
.GetAwaiter().GetResult();
39+
40+
var http = new HttpClient { BaseAddress = endpoint };
41+
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
42+
43+
return new OllamaApiClient(http, opts.Model);
44+
}
45+
}

src/CloudEngAgent.Infrastructure/Backends/ChatClientFactory.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public sealed class ChatClientFactory : IChatClientFactory, IDisposable
2121
private readonly IOptionsMonitor<GitHubModelsOptions> _gitHubModelsOptions;
2222
private readonly IOptionsMonitor<AzureFoundryOptions> _azureFoundryOptions;
2323
private readonly IOptionsMonitor<AnthropicOptions> _anthropicOptions;
24+
private readonly IOptionsMonitor<OllamaOptions> _ollamaOptions;
2425
private readonly IBackendSecretResolver _secrets;
2526
private readonly TokenCredential _credential;
2627
private readonly ILoggerFactory _loggerFactory;
@@ -33,6 +34,7 @@ public ChatClientFactory(
3334
IOptionsMonitor<GitHubModelsOptions> gitHubModelsOptions,
3435
IOptionsMonitor<AzureFoundryOptions> azureFoundryOptions,
3536
IOptionsMonitor<AnthropicOptions> anthropicOptions,
37+
IOptionsMonitor<OllamaOptions> ollamaOptions,
3638
IBackendSecretResolver secrets,
3739
TokenCredential credential,
3840
ILoggerFactory loggerFactory,
@@ -43,6 +45,7 @@ public ChatClientFactory(
4345
_gitHubModelsOptions = gitHubModelsOptions;
4446
_azureFoundryOptions = azureFoundryOptions;
4547
_anthropicOptions = anthropicOptions;
48+
_ollamaOptions = ollamaOptions;
4649
_secrets = secrets;
4750
_credential = credential;
4851
_loggerFactory = loggerFactory;
@@ -78,6 +81,10 @@ private IChatClient CreateCore(BackendId backend)
7881
{
7982
raw = AnthropicChatClientAdapter.Create(_anthropicOptions.CurrentValue, _secrets);
8083
}
84+
else if (backend == BackendId.Ollama)
85+
{
86+
raw = OllamaChatClientAdapter.Create(_ollamaOptions.CurrentValue, _secrets);
87+
}
8188
else
8289
{
8390
throw new InvalidOperationException($"Unknown backend '{backend}'.");

src/CloudEngAgent.Infrastructure/Backends/Options/BackendOptionsValidators.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,22 @@ public static ValidateOptionsResult ValidateAnthropic(AnthropicOptions opts)
9090
return Summarize(errors);
9191
}
9292

93+
public static ValidateOptionsResult ValidateOllama(OllamaOptions opts)
94+
{
95+
// No Endpoint → user hasn't configured this backend; skip.
96+
if (string.IsNullOrEmpty(opts.Endpoint))
97+
return ValidateOptionsResult.Success;
98+
99+
var errors = new List<string>();
100+
101+
if (string.IsNullOrEmpty(opts.Model))
102+
errors.Add($"'{OllamaOptions.SectionName}:Model' is required when Endpoint is configured.");
103+
104+
// ApiKeyRef is optional; AuthMode is intentionally not enforced for Ollama.
105+
106+
return Summarize(errors);
107+
}
108+
93109
// ── Helpers ───────────────────────────────────────────────────────────────
94110

95111
private static bool IsApiKeyMode(string authMode) =>
@@ -140,3 +156,9 @@ internal sealed class AnthropicOptionsValidator : IValidateOptions<AnthropicOpti
140156
public ValidateOptionsResult Validate(string? name, AnthropicOptions options) =>
141157
BackendOptionsValidator.ValidateAnthropic(options);
142158
}
159+
160+
internal sealed class OllamaOptionsValidator : IValidateOptions<OllamaOptions>
161+
{
162+
public ValidateOptionsResult Validate(string? name, OllamaOptions options) =>
163+
BackendOptionsValidator.ValidateOllama(options);
164+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace CloudEngAgent.Infrastructure.Backends.Options;
2+
3+
/// <summary>
4+
/// Options for the Ollama backend (local or proxied). Binds to <see cref="SectionName"/>.
5+
/// </summary>
6+
/// <remarks>
7+
/// Ollama is unauthenticated by default. <see cref="ApiKeyRef"/> is optional and is only
8+
/// used for proxied/secured deployments where a Bearer token must be sent on each request.
9+
/// The inherited <see cref="BackendOptions.AuthMode"/> property is <b>not</b> honored for
10+
/// Ollama; presence/absence of <see cref="ApiKeyRef"/> alone determines authentication.
11+
/// </remarks>
12+
public sealed record OllamaOptions : BackendOptions
13+
{
14+
public const string SectionName = "Backends:ollama";
15+
16+
/// <summary>Base URI of the Ollama server (e.g. <c>http://localhost:11434</c>).</summary>
17+
public string Endpoint { get; init; } = string.Empty;
18+
19+
/// <summary>Model tag to use by default (e.g. <c>llama3.1:8b</c>).</summary>
20+
public string Model { get; init; } = string.Empty;
21+
22+
/// <summary>
23+
/// Optional secret reference for a Bearer token, for Ollama deployments fronted by
24+
/// an authenticating proxy. Leave empty for plain unauthenticated Ollama.
25+
/// </summary>
26+
public string ApiKeyRef { get; init; } = string.Empty;
27+
}

src/CloudEngAgent.Infrastructure/CloudEngAgent.Infrastructure.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
<PackageReference Include="Azure.AI.Inference" />
3939
<PackageReference Include="OpenAI" />
4040
<PackageReference Include="Anthropic.SDK" />
41+
<PackageReference Include="OllamaSharp" />
4142

4243
<!-- MCP client -->
4344
<PackageReference Include="ModelContextProtocol" />

src/CloudEngAgent.Infrastructure/ServiceCollectionExtensions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ public static IServiceCollection AddInfrastructure(
9898
.ValidateOnStart();
9999
services.AddSingleton<IValidateOptions<AnthropicOptions>, AnthropicOptionsValidator>();
100100

101+
services.AddOptions<OllamaOptions>()
102+
.Bind(configuration.GetSection(OllamaOptions.SectionName))
103+
.ValidateOnStart();
104+
services.AddSingleton<IValidateOptions<OllamaOptions>, OllamaOptionsValidator>();
105+
101106
// ── Secrets ────────────────────────────────────────────────────────────
102107
var kvUri = configuration["KeyVault:Uri"];
103108
if (!string.IsNullOrEmpty(kvUri))

tests/CloudEngAgent.Api.Tests/Backends/BackendOptionsValidatorTests.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,47 @@ public void Anthropic_FullyValid_Passes()
171171
var result = BackendOptionsValidator.ValidateAnthropic(opts);
172172
result.Succeeded.Should().BeTrue();
173173
}
174+
175+
// ── Ollama ────────────────────────────────────────────────────────────
176+
177+
[Fact]
178+
public void Ollama_Empty_Passes()
179+
{
180+
var result = BackendOptionsValidator.ValidateOllama(new OllamaOptions());
181+
result.Succeeded.Should().BeTrue();
182+
}
183+
184+
[Fact]
185+
public void Ollama_EndpointOnly_Fails_WithHelpfulMessage()
186+
{
187+
var opts = new OllamaOptions { Endpoint = "http://localhost:11434" };
188+
var result = BackendOptionsValidator.ValidateOllama(opts);
189+
result.Succeeded.Should().BeFalse();
190+
result.FailureMessage.Should().Contain("Model");
191+
}
192+
193+
[Fact]
194+
public void Ollama_FullyValid_Passes()
195+
{
196+
var opts = new OllamaOptions
197+
{
198+
Endpoint = "http://localhost:11434",
199+
Model = "llama3.1:8b"
200+
};
201+
var result = BackendOptionsValidator.ValidateOllama(opts);
202+
result.Succeeded.Should().BeTrue();
203+
}
204+
205+
[Fact]
206+
public void Ollama_WithApiKeyRef_Passes()
207+
{
208+
var opts = new OllamaOptions
209+
{
210+
Endpoint = "https://ollama-proxy.example.com",
211+
Model = "llama3.1:8b",
212+
ApiKeyRef = "ollama-proxy-token"
213+
};
214+
var result = BackendOptionsValidator.ValidateOllama(opts);
215+
result.Succeeded.Should().BeTrue();
216+
}
174217
}

0 commit comments

Comments
 (0)