src/TechToolbox.Agent/Agent/LlmClient.cs

using System.Net.Http.Json;
using System.Text.Json;
 
namespace TechToolbox.Agent.Agent;
 
public class LlmClient
{
    private static readonly int RequestTimeoutSeconds = GetTimeoutSeconds();
 
    private readonly HttpClient _http;
    private readonly string _model;
 
    public Action<string>? DiagnosticTrace { get; set; }
 
    public LlmClient(string model)
    {
        _model = model;
        _http = new HttpClient
        {
            Timeout = Timeout.InfiniteTimeSpan
        };
    }
 
    public virtual async Task<LlmResponse> GenerateAsync(string prompt)
    {
        if (string.IsNullOrWhiteSpace(prompt))
            return new LlmResponse("");
 
        var payload = new
        {
            model = _model,
            prompt = prompt,
            stream = false
        };
 
        HttpResponseMessage response;
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(RequestTimeoutSeconds));
 
        try
        {
            response = await _http.PostAsJsonAsync(
                "http://localhost:11434/api/generate",
                payload,
                new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase },
                cts.Token);
            Trace($"HTTP status={(int)response.StatusCode} ({response.StatusCode})");
        }
        catch (OperationCanceledException)
        {
            Trace($"Request timeout after {RequestTimeoutSeconds}s");
            return new LlmResponse($"LLM request timed out after {RequestTimeoutSeconds} seconds.");
        }
        catch (Exception ex)
        {
            Trace($"Request failed: {ex.GetType().Name}: {ex.Message}");
            return new LlmResponse($"LLM request failed: {ex.Message}");
        }
 
        if (!response.IsSuccessStatusCode)
        {
            string err;
            try
            {
                err = await response.Content.ReadAsStringAsync(cts.Token);
                Trace($"Non-success body length={err.Length}");
            }
            catch (OperationCanceledException)
            {
                err = "(error body read timed out)";
                Trace("Non-success body read timed out");
            }
 
            return new LlmResponse($"LLM error: {response.StatusCode} - {err}");
        }
 
        try
        {
            var body = await response.Content.ReadAsStringAsync(cts.Token);
            Trace($"Success body length={body.Length}");
 
            JsonDocument doc;
            try
            {
                doc = JsonDocument.Parse(body);
            }
            catch (JsonException jsonEx)
            {
                Trace($"JSON parse failed: {jsonEx.Message}");
                return new LlmResponse($"LLM response parse failed: {jsonEx.Message}");
            }
 
            using (doc)
            {
                if (!doc.RootElement.TryGetProperty("response", out var responseElement))
                {
                    Trace("JSON parsed but missing 'response' field");
                    return new LlmResponse("");
                }
 
                if (responseElement.ValueKind != JsonValueKind.String)
                {
                    Trace($"JSON parsed but 'response' field is non-string ({responseElement.ValueKind})");
                    return new LlmResponse("");
                }
 
                var responseText = responseElement.GetString() ?? "";
                Trace($"Parsed 'response' length={responseText.Length}");
                return new LlmResponse(responseText);
            }
        }
        catch (OperationCanceledException)
        {
            Trace($"Response read timeout after {RequestTimeoutSeconds}s");
            return new LlmResponse($"LLM response read timed out after {RequestTimeoutSeconds} seconds.");
        }
        catch (Exception ex)
        {
            Trace($"Response handling failed: {ex.GetType().Name}: {ex.Message}");
            return new LlmResponse($"LLM response parse failed: {ex.Message}");
        }
    }
 
    private void Trace(string message)
    {
        try
        {
            DiagnosticTrace?.Invoke(message);
        }
        catch
        {
            // Diagnostic tracing must never break LLM calls.
        }
    }
 
    private static int GetTimeoutSeconds()
    {
        const int defaultSeconds = 180;
        const int minSeconds = 15;
        const int maxSeconds = 600;
 
        var raw = Environment.GetEnvironmentVariable("TT_AGENT_LLM_TIMEOUT_SECONDS");
        if (int.TryParse(raw, out var parsed))
        {
            return Math.Clamp(parsed, minSeconds, maxSeconds);
        }
 
        return defaultSeconds;
    }
}
 
public record LlmResponse(string Text);
 
public class OllamaResponse
{
    public string? Model { get; set; }
    public string? CreatedAt { get; set; }
    public string? Response { get; set; }
    public bool Done { get; set; }
    public int? TotalDuration { get; set; }
    public int? LoadDuration { get; set; }
    public int? PromptEvalCount { get; set; }
    public int? PromptEvalDuration { get; set; }
    public int? EvalCount { get; set; }
    public int? EvalDuration { get; set; }
}