ci / build-test (pull_request) Successful in 2m53s
Описание живёт только в интерфейсе: удалены классовые summary и док-блоки членов-реализаций (28 файлов). Правило уточнено в §11.
237 lines
10 KiB
C#
237 lines
10 KiB
C#
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 < 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;
|
||
}
|
||
}
|