Files
Deal/src/core/Deal.Infrastructure/Integrations/Services/AiConnectionChecker.cs
T
stepan b8570e3197
ci / build-test (pull_request) Successful in 2m53s
Убрать XML-доки из реализаций интерфейсов
Описание живёт только в интерфейсе: удалены классовые summary и
док-блоки членов-реализаций (28 файлов). Правило уточнено в §11.
2026-09-13 14:15:23 +03:00

237 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
using Deal.Infrastructure.Integrations.Extensions;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Infrastructure.Integrations.Services;
public sealed class AiConnectionChecker : IAiConnectionChecker
{
/// <summary>
/// Таймаут HTTP-запроса проверки в секундах; применяется DI-регистрацией клиента.
/// </summary>
public const int RequestTimeoutSeconds = 12;
private const string LocalServerMessageTemplate = "Локальный сервер «{0}» (ping в проде)";
// Сообщение ветки «API-ключ не задан».
private const string NoApiKeyMessage = "Не задан API-ключ";
// Сообщение успешной проверки (HTTP &lt; 400).
private const string SuccessMessage = "Подключение успешно";
// Сообщение ветки «ключ не принят» (HTTP 401/403).
private const string KeyRejectedMessageTemplate = "Ключ не принят (HTTP {0}) — проверьте ключ и доступ к модели";
// Сообщение ветки «иной HTTP-код» (≥ 400, кроме 401/403).
private const string HttpErrorMessageTemplate = "HTTP {0} — проверьте Base URL и модель";
// Сообщение ветки сетевого сбоя (деталь — текст исключения).
private const string ConnectionErrorMessageTemplate = "Ошибка соединения: {0}";
// Сообщение ветки таймаута HttpClient.
private const string TimeoutMessage = "Ошибка соединения: превышен таймаут ожидания";
// Сообщение SSRF-гейта: провайдер вне фиксированного каталога (allowlist).
private const string ProviderNotAllowedMessage = "Провайдер не из списка разрешённых";
// Сообщение SSRF-гейта: base URL не абсолютный http(s).
private const string InvalidBaseUrlMessage = "Недопустимый Base URL (ожидается http/https)";
// Значение api_style провайдера Anthropic (AiProviderDefinition.ApiStyle).
private const string AnthropicApiStyle = "anthropic";
// Путь списка моделей Anthropic: {base}/v1/models.
private const string AnthropicModelsPath = "/v1/models";
// Путь списка моделей OpenAI-совместимых: {base}/models.
private const string OpenAiModelsPath = "/models";
// Сообщение SSRF-гейта: base URL указывает на приватный/локальный адрес.
private const string PrivateEndpointNotAllowedMessage =
"Недопустимый Base URL (приватный/локальный адрес недоступен для проверки)";
// Заголовок ключа Anthropic.
private const string ApiKeyHeaderName = "x-api-key";
// Заголовок версии протокола Anthropic.
private const string AnthropicVersionHeaderName = "anthropic-version";
// Значение версии протокола Anthropic.
private const string AnthropicVersionHeaderValue = "2023-06-01";
private readonly HttpClient _httpClient;
/// <summary>
/// Создаёт проверку поверх HttpClient.
/// </summary>
/// <param name="httpClient">Клиент с таймаутом 12 с (DI: AddHttpClient в Deal.Api).</param>
public AiConnectionChecker(HttpClient httpClient)
{
ArgumentNullException.ThrowIfNull(httpClient);
_httpClient = httpClient;
}
async Task<AiCheckResultDto> IAiConnectionChecker.CheckAsync(AiCheckRequest request, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request);
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(provider => provider.Id == request.ProviderId);
string name = meta?.Name ?? request.ProviderId;
// SSRF-гейт (allowlist, preflight): проверка возможна только для провайдера фиксированного
// каталога AiProviders. В штатном потоке недостижимо (PATCH-гейт aiProvider/aiConfigs в
// SettingsService) — защита от ручного изменения БД/повреждённого хранилища.
if (meta is null)
{
return BuildResult(request, name, ok: false, message: ProviderNotAllowedMessage);
}
if (request.IsLocal)
{
return BuildResult(request, name, ok: true, message: string.Format(LocalServerMessageTemplate, name));
}
if (string.IsNullOrEmpty(request.ApiKey))
{
return BuildResult(request, name, ok: false, message: NoApiKeyMessage);
}
if (!TryBuildModelsUri(request.BaseUrl, request.ApiStyle, out Uri? modelsUri))
{
return BuildResult(request, name, ok: false, message: InvalidBaseUrlMessage);
}
// SSRF-гейт (Security review): проверка подключения выполняется только к публичным адресам.
// Private/loopback/link-local литералы и localhost запрещены для не-local провайдеров (локальные
// провайдеры — ветка IsLocal выше, HTTP для них не выполняется вовсе). DNS-имена не резолвятся
// здесь (полный egress-контроль с резолвом — на уровне сетевого периметра/прокси).
if (modelsUri.IsPrivateEndpoint())
{
return BuildResult(request, name, ok: false, message: PrivateEndpointNotAllowedMessage);
}
try
{
using HttpRequestMessage httpRequest = new(HttpMethod.Get, modelsUri);
AddAuthHeaders(httpRequest, request.ApiKey, request.ApiStyle);
using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest, ct);
int statusCode = (int)response.StatusCode;
if (statusCode < 400)
{
return BuildResult(request, name, ok: true, message: SuccessMessage);
}
if (statusCode is 401 or 403)
{
return BuildResult(request, name, ok: false,
message: string.Format(KeyRejectedMessageTemplate, statusCode));
}
return BuildResult(request, name, ok: false,
message: string.Format(HttpErrorMessageTemplate, statusCode));
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return BuildResult(request, name, ok: false, message: TimeoutMessage);
}
catch (HttpRequestException exception)
{
return BuildResult(request, name, ok: false,
message: string.Format(ConnectionErrorMessageTemplate, ConnectionErrorDetail(exception)));
}
}
private static AiCheckResultDto BuildResult(
AiCheckRequest request,
string name,
bool ok,
string message)
{
return new AiCheckResultDto(
Ok: ok,
Message: message,
Provider: request.ProviderId,
Name: name,
Base: request.BaseUrl,
Model: request.Model,
Local: request.IsLocal,
KeySet: !string.IsNullOrEmpty(request.ApiKey),
KeyMasked: MaskKey(request.ApiKey));
}
private static string MaskKey(string key)
{
if (string.IsNullOrEmpty(key))
{
return string.Empty;
}
if (key.Length <= 8)
{
return string.Concat(key.AsSpan(0, 1), "…");
}
return string.Concat(key.AsSpan(0, 4), "…", key.AsSpan(key.Length - 4));
}
private static bool TryBuildModelsUri(
string baseUrl,
string? apiStyle,
[NotNullWhen(true)] out Uri? modelsUri)
{
modelsUri = null;
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? parsed)
|| (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
{
return false;
}
string root = baseUrl.TrimEnd('/');
string relativePath = apiStyle == AnthropicApiStyle ? AnthropicModelsPath : OpenAiModelsPath;
if (!Uri.TryCreate(root + relativePath, UriKind.Absolute, out Uri? endpoint))
{
return false;
}
modelsUri = endpoint;
return true;
}
// Заголовки аутентификации: Bearer (OpenAI-совместимые) или x-api-key + версия (Anthropic).
// httpRequest: Запрос списка моделей.
// apiKey: Ключ открытым текстом (непустой — ветка ключа пройдена).
// apiStyle: Стиль API провайдера.
private static void AddAuthHeaders(
HttpRequestMessage httpRequest,
string apiKey,
string? apiStyle)
{
if (apiStyle == AnthropicApiStyle)
{
httpRequest.Headers.TryAddWithoutValidation(ApiKeyHeaderName, apiKey);
httpRequest.Headers.TryAddWithoutValidation(AnthropicVersionHeaderName, AnthropicVersionHeaderValue);
return;
}
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
}
// Исключение HTTP-слоя.
// Возвращает: Человекочитаемый текст причины.
private static string ConnectionErrorDetail(HttpRequestException exception)
{
if (!string.IsNullOrWhiteSpace(exception.Message))
{
return exception.Message;
}
return exception.InnerException?.Message ?? exception.GetType().Name;
}
}