summaryrefslogtreecommitdiff
path: root/src/Agent.Core/Ollama/OllamaClient.cs
blob: dde8478c3b2d4c0c9ae72215a889576d8db7b028 (plain)
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 ?? [], result.PromptEvalCount);
            }
            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;
}