< Summary

Information
Class: Services.ApiServices.OpenRouterApi.OpenRouterApiClient
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/OpenRouterApi/OpenRouterApiClient.cs
Line coverage
86%
Covered lines: 80
Uncovered lines: 13
Coverable lines: 93
Total lines: 199
Line coverage: 86%
Branch coverage
80%
Covered branches: 37
Total branches: 46
Branch coverage: 80.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_IsConfigured()75%44100%
CompleteChatAsync()100%44100%
StreamChatAsync()76.47%523475%
IsNetworkUnavailable(...)100%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/OpenRouterApi/OpenRouterApiClient.cs

#LineLine coverage
 1using System.IO;
 2using System.Runtime.CompilerServices;
 3using System.Text;
 4using Microsoft.Extensions.Logging;
 5using Newtonsoft.Json;
 6using Services.ApiServices.OpenRouterApi.Models;
 7
 8namespace Services.ApiServices.OpenRouterApi;
 9
 10public class OpenRouterApiClient
 11{
 12    private readonly HttpClient _httpClient;
 13    private readonly ILogger<OpenRouterApiClient> _logger;
 14    private readonly OpenRouterApiConfig? _config;
 15
 4116    public OpenRouterApiClient(
 4117        HttpClient httpClient,
 4118        ILogger<OpenRouterApiClient> logger,
 4119        OpenRouterApiConfig? config = null)
 20    {
 4121        _httpClient = httpClient;
 4122        _logger     = logger;
 4123        _config     = config;
 4124    }
 25
 3326    public bool IsConfigured => _config != null
 3327        ? _config.IsConfigured
 3328        : (_httpClient.DefaultRequestHeaders.Authorization == null || !string.IsNullOrWhiteSpace(_httpClient.DefaultRequ
 29
 30    public async Task<ChatCompletionResponse?> CompleteChatAsync(
 31        ChatCompletionRequest request,
 32        CancellationToken ct = default)
 33    {
 2734        if (!IsConfigured)
 35        {
 136            _logger.LogInformation("OpenRouter: API-ключ не настроен, исходящий запрос пропущен");
 137            return null;
 38        }
 39
 40        try
 41        {
 2642            var json    = JsonConvert.SerializeObject(request);
 2643            var content = new StringContent(json, Encoding.UTF8, "application/json");
 44
 2645            using var response = await _httpClient.PostAsync("chat/completions", content, ct);
 2146            response.EnsureSuccessStatusCode();
 47
 1848            var body = await response.Content.ReadAsStringAsync(ct);
 1849            _logger.LogDebug("OpenRouter response received, length={Length}", body.Length);
 1850            return JsonConvert.DeserializeObject<ChatCompletionResponse>(body);
 51        }
 52        // Регрессия #2161: сетевой сбой (нет HTTP-ответа) — недоступность openrouter.ai, а не ошибка
 53        // запроса; OutboundRequestLoggingHandler уже записал детали.
 554        catch (HttpRequestException ex) when (IsNetworkUnavailable(ex))
 55        {
 156            _logger.LogInformation("OpenRouter недоступен: {Message}", ex.Message);
 157            return null;
 58        }
 59        // Регрессия #2163: 401 Unauthorized при отсутствующем/недействительном ключе логируется
 60        // информационно, не засоряя errors-*.log.
 461        catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
 62        {
 163            _logger.LogInformation("OpenRouter: авторизация не пройдена (HTTP 401). Проверьте OpenRouter:ApiKey в конфиг
 164            return null;
 65        }
 366        catch (HttpRequestException ex)
 67        {
 368            _logger.LogWarning(ex, "OpenRouter HTTP error");
 369            return null;
 70        }
 271        catch (OperationCanceledException ex)
 72        {
 273            if (ct.IsCancellationRequested)
 74            {
 175                _logger.LogInformation("OpenRouter request canceled by caller");
 76            }
 77            else
 78            {
 179                _logger.LogInformation("OpenRouter timeout: {Message}", ex.Message);
 80            }
 281            return null;
 82        }
 183        catch (Exception ex)
 84        {
 185            _logger.LogError(ex, "OpenRouter unexpected error");
 186            return null;
 87        }
 2788    }
 89
 90    /// <summary>
 91    /// Стриминг ответа через SSE. Возвращает текстовые чанки по мере поступления.
 92    /// Когда стриминг завершается, вызывает <paramref name="onUsage"/> с данными о потреблении токенов.
 93    /// </summary>
 94    public async IAsyncEnumerable<string> StreamChatAsync(
 95        ChatCompletionRequest request,
 96        Action<UsageInfo>? onUsage = null,
 97        [EnumeratorCancellation] CancellationToken ct = default)
 98    {
 599        if (!IsConfigured)
 100        {
 1101            _logger.LogInformation("OpenRouter stream: API-ключ не настроен, исходящий запрос пропущен");
 1102            yield break;
 103        }
 104
 4105        request.Stream        = true;
 4106        request.StreamOptions = new StreamOptions { IncludeUsage = true };
 107
 4108        var json    = JsonConvert.SerializeObject(request);
 4109        var content = new StringContent(json, Encoding.UTF8, "application/json");
 110
 111        HttpResponseMessage response;
 112        try
 113        {
 4114            var req = new HttpRequestMessage(HttpMethod.Post, "chat/completions") { Content = content };
 4115            response = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
 2116            response.EnsureSuccessStatusCode();
 1117        }
 1118        catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
 119        {
 1120            _logger.LogInformation("OpenRouter stream: авторизация не пройдена (HTTP 401). Проверьте OpenRouter:ApiKey в
 1121            yield break;
 122        }
 2123        catch (OperationCanceledException ex)
 124        {
 2125            if (ct.IsCancellationRequested)
 126            {
 1127                _logger.LogInformation("OpenRouter stream: canceled by caller");
 128            }
 129            else
 130            {
 1131                _logger.LogInformation("OpenRouter stream: request timed out ({Message})", ex.Message);
 132            }
 2133            yield break;
 134        }
 0135        catch (Exception ex)
 136        {
 0137            _logger.LogWarning(ex, "OpenRouter stream: request failed");
 0138            yield break;
 139        }
 140
 1141        using var _ = response;
 142        Stream stream;
 143        try
 144        {
 1145            stream = await response.Content.ReadAsStreamAsync(ct);
 1146        }
 0147        catch (OperationCanceledException ex)
 148        {
 0149            if (ct.IsCancellationRequested)
 150            {
 0151                _logger.LogInformation("OpenRouter stream: canceled while opening body stream");
 152            }
 153            else
 154            {
 0155                _logger.LogInformation("OpenRouter stream: timed out while opening body stream ({Message})", ex.Message)
 156            }
 0157            yield break;
 158        }
 0159        catch (Exception ex)
 160        {
 0161            _logger.LogWarning(ex, "OpenRouter stream: cannot read body");
 0162            yield break;
 163        }
 164
 1165        UsageInfo? lastUsage = null;
 166
 1167        using var reader = new StreamReader(stream, Encoding.UTF8);
 5168        while (!reader.EndOfStream && !ct.IsCancellationRequested)
 169        {
 170            string? line;
 10171            try { line = await reader.ReadLineAsync(ct); }
 0172            catch { yield break; }
 173
 5174            if (line == null) break;
 5175            if (!line.StartsWith("data: ", StringComparison.Ordinal)) continue;
 176
 3177            var data = line["data: ".Length..];
 3178            if (data == "[DONE]") break;
 179
 180            ChatCompletionChunk? chunk;
 4181            try { chunk = JsonConvert.DeserializeObject<ChatCompletionChunk>(data); }
 0182            catch { continue; }
 183
 2184            if (chunk?.Usage != null)
 1185                lastUsage = chunk.Usage;
 186
 2187            var text = chunk?.Choices?.FirstOrDefault()?.Delta?.Content;
 2188            if (!string.IsNullOrEmpty(text))
 2189                yield return text;
 190        }
 191
 1192        if (lastUsage != null)
 1193            onUsage?.Invoke(lastUsage);
 5194    }
 195
 196    private static bool IsNetworkUnavailable(HttpRequestException exception) =>
 5197        exception.HttpRequestError is HttpRequestError.NameResolutionError or HttpRequestError.ConnectionError
 5198        || exception.InnerException is System.Net.Sockets.SocketException;
 199}