1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
|
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Agent.Core.Tools;
namespace Agent.Core.Ollama;
public sealed class OllamaClient : IOllamaClient
{
private static readonly TimeSpan[] RetryDelays = [TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1)];
private readonly HttpClient _httpClient;
private readonly OllamaSettings _settings;
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
private bool? _visionCapability;
public OllamaClient(HttpClient httpClient, OllamaSettings settings, Func<TimeSpan, CancellationToken, Task>? delay = null)
{
_httpClient = httpClient;
_httpClient.BaseAddress = settings.Endpoint;
_settings = settings;
_delay = delay ?? Task.Delay;
}
public async Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken)
=> await ChatCoreAsync(messages, temperatureOverride, null, null, cancellationToken).ConfigureAwait(false);
public async Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken)
=> await ChatCoreAsync(messages, temperatureOverride, tools, null, cancellationToken).ConfigureAwait(false);
public async Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken)
=> await ChatCoreAsync(messages, temperatureOverride, null, format, cancellationToken).ConfigureAwait(false);
public async Task<bool?> SupportsVisionAsync(CancellationToken cancellationToken)
{
if (_visionCapability is not null) return _visionCapability;
try
{
using HttpResponseMessage response = await _httpClient.PostAsJsonAsync("api/show", new { model = _settings.Model }, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) return null;
using JsonDocument document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), cancellationToken: cancellationToken).ConfigureAwait(false);
if (!document.RootElement.TryGetProperty("capabilities", out JsonElement capabilities) || capabilities.ValueKind != JsonValueKind.Array) return null;
_visionCapability = capabilities.EnumerateArray().Any(static item => item.GetString()?.Equals("vision", StringComparison.OrdinalIgnoreCase) == true);
return _visionCapability;
}
catch (HttpRequestException) { return null; }
}
private async Task<OllamaResult> ChatCoreAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition>? tools, JsonElement? format, CancellationToken cancellationToken)
{
Dictionary<string, JsonElement> options = _settings.Options.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
if (temperatureOverride is not null) options["temperature"] = JsonSerializer.SerializeToElement(temperatureOverride.Value);
var request = new OllamaChatRequest
{
Model = _settings.Model,
Messages = messages,
Stream = false,
Think = _settings.Think,
KeepAlive = _settings.KeepAlive,
Options = options,
Tools = tools?.Select(static tool => new OllamaToolDefinition("function", new OllamaFunctionDefinition(tool.Name, tool.Description, tool.Parameters))).ToArray(),
Format = format
};
for (int attempt = 0; ; attempt++)
{
try
{
using HttpResponseMessage response = await _httpClient.PostAsJsonAsync("api/chat", request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
bool transient = IsTransient(response.StatusCode);
if (transient && attempt < RetryDelays.Length)
{
await _delay(RetryDelays[attempt], cancellationToken).ConfigureAwait(false);
continue;
}
throw new OllamaException($"Ollama returned HTTP {(int)response.StatusCode}: {body}", transient);
}
OllamaChatResponse result = await response.Content.ReadFromJsonAsync<OllamaChatResponse>(cancellationToken: cancellationToken).ConfigureAwait(false)
?? throw new OllamaException("Ollama returned an empty response.", false);
if (!string.IsNullOrWhiteSpace(result.Error)) throw new OllamaException(result.Error, false);
if (result.Message is null) throw new OllamaException("Ollama response has no message.", false);
return new OllamaResult(result.Message.Content, result.PromptEvalCount, result.EvalCount, result.Message.ToolCalls ?? []);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
if (attempt >= RetryDelays.Length) throw new OllamaException("Ollama is unavailable after three retries.", true, ex);
await _delay(RetryDelays[attempt], cancellationToken).ConfigureAwait(false);
}
}
}
private static bool IsTransient(HttpStatusCode statusCode) => statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || (int)statusCode >= 500;
}
|