/
afanasevn
/
PdfEncoder
Обзор
Документация
Войти
/
afanasevn
/
PdfEncoder
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
src/PdfEncoder.Infrastructure/Llm/LlmClient.cs
185 строк
6 KB
IBS\NAfanasev
Remote LLM base functional
01 июл 2026, 15:54
01 июл 2026, 15:54
05aa80e
Код
Авторство
О чём код?
using System.Diagnostics; using System.Net; using System.Text.Json; using Microsoft.Extensions.Options; using PdfEncoder.Application.Abstractions; using PdfEncoder.Application.Options; using PdfEncoder.Domain.Errors; using PdfEncoder.Domain.Models; namespace PdfEncoder.Infrastructure.Llm; /// <summary> /// Structured LLM wrapper с retry, json_object fallback и usage logging. /// </summary> public sealed class LlmClient( OpenAiCompatibleChatClient chatClient, ILlmUsageLogStore usageLogStore, PromptTemplateProvider promptProvider, IOptions<LlmModelOptions> options) : ILlmClient { private static readonly int[] RateLimitBackoffSeconds = [1, 2, 4]; /// <inheritdoc /> public async Task<LlmCompletionResult<T>> CompleteStructuredAsync<T>( LlmStructuredRequest request, CancellationToken ct) { var llmOptions = options.Value; using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(TimeSpan.FromSeconds(llmOptions.TimeoutSeconds)); var preferNative = request.PreferNativeJsonObject && llmOptions.UseNativeJsonObject; Exception? lastError = null; foreach (var useJsonObject in EnumerateJsonModes(preferNative)) { try { return await ExecuteWithRetriesAsync<T>( request, useJsonObject, invalidJsonRetries: 1, timeoutCts.Token); } catch (LlmException ex) when (ex.ErrorCode == "llm-invalid-response" && useJsonObject && preferNative) { lastError = ex; } catch (HttpRequestException ex) when (useJsonObject && preferNative && IsUnsupportedJsonObject(ex)) { lastError = ex; } } throw lastError ?? new LlmException( "llm-invalid-response", "Failed to obtain valid structured JSON from LLM.", statusCode: 500); } private async Task<LlmCompletionResult<T>> ExecuteWithRetriesAsync<T>( LlmStructuredRequest request, bool useJsonObject, int invalidJsonRetries, CancellationToken ct) { var attemptsLeft = RateLimitBackoffSeconds.Length; var jsonFixAttemptsLeft = invalidJsonRetries; while (true) { try { return await CompleteOnceAsync<T>(request, useJsonObject, ct); } catch (HttpRequestException ex) when (IsRateLimited(ex) && attemptsLeft > 0) { var delay = RateLimitBackoffSeconds[^attemptsLeft]; attemptsLeft--; await Task.Delay(TimeSpan.FromSeconds(delay), ct); } catch (HttpRequestException ex) when (IsTransientServerError(ex) && attemptsLeft > 0) { var delay = RateLimitBackoffSeconds[^attemptsLeft]; attemptsLeft--; await Task.Delay(TimeSpan.FromSeconds(delay), ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw new LlmException( "llm-unavailable", $"LLM request timed out after {options.Value.TimeoutSeconds}s.", statusCode: 503); } catch (JsonException ex) when (jsonFixAttemptsLeft > 0) { jsonFixAttemptsLeft--; request = request with { SystemPrompt = promptProvider.GetTemplate("json-fix-system.txt"), UserPrompt = $"{request.UserPrompt}\n\nНекорректный ответ модели:\n{ex.Message}" }; } catch (JsonException ex) { throw new LlmException( "llm-invalid-response", $"LLM returned invalid JSON: {ex.Message}", statusCode: 500, innerException: ex); } catch (HttpRequestException ex) { throw new LlmException( "llm-unavailable", ex.Message, statusCode: 503, innerException: ex); } } } private async Task<LlmCompletionResult<T>> CompleteOnceAsync<T>( LlmStructuredRequest request, bool useJsonObject, CancellationToken ct) { var stopwatch = Stopwatch.StartNew(); var completion = await chatClient.CompleteAsync( request.Model, request.SystemPrompt, request.UserPrompt, useJsonObject, ct); stopwatch.Stop(); var data = LlmJsonResponseParser.Parse<T>(completion.Content); await usageLogStore.AppendAsync(new LlmUsageLog { Id = Guid.NewGuid(), DocumentJobId = request.DocumentJobId, StepName = request.StepName, ModelName = request.Model, PromptTokens = completion.PromptTokens, CompletionTokens = completion.CompletionTokens, LatencyMs = (int)stopwatch.ElapsedMilliseconds, CreatedAt = DateTime.UtcNow }, ct); return new LlmCompletionResult<T>( data, completion.PromptTokens, completion.CompletionTokens, (int)stopwatch.ElapsedMilliseconds); } private static IEnumerable<bool> EnumerateJsonModes(bool preferNative) { if (preferNative) { yield return true; } yield return false; } private static bool IsRateLimited(HttpRequestException ex) { return ex.StatusCode == HttpStatusCode.TooManyRequests; } private static bool IsTransientServerError(HttpRequestException ex) { return ex.StatusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout; } private static bool IsUnsupportedJsonObject(HttpRequestException ex) { return ex.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.NotImplemented; } }