341 lines
14 KiB
C#
341 lines
14 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace Network
|
|
{
|
|
public sealed class OpenAIHttpNetworkService : IDisposable
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private bool _disposed;
|
|
|
|
public OpenAIHttpNetworkService()
|
|
{
|
|
HttpClientHandler handler = new HttpClientHandler
|
|
{
|
|
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
|
|
};
|
|
|
|
_httpClient = new HttpClient(handler);
|
|
}
|
|
|
|
public async Task<OpenAIHeartbeatResult> SendHeartbeatAsync(string url, string apiKey, int timeoutSeconds,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(url))
|
|
{
|
|
return OpenAIHeartbeatResult.CreateFailure(false, false, 0, "Heartbeat failed. URL is empty.");
|
|
}
|
|
|
|
using (HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, url))
|
|
{
|
|
ApplyAuthorization(request, apiKey);
|
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
|
|
|
using (CancellationTokenSource timeoutCts = new CancellationTokenSource(
|
|
TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds))))
|
|
{
|
|
using (CancellationTokenSource linkedCts =
|
|
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token))
|
|
{
|
|
try
|
|
{
|
|
using (HttpResponseMessage response = await _httpClient
|
|
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token)
|
|
.ConfigureAwait(false))
|
|
{
|
|
long statusCode = (long)response.StatusCode;
|
|
bool isHttpSuccess = response.IsSuccessStatusCode;
|
|
if (isHttpSuccess)
|
|
{
|
|
return OpenAIHeartbeatResult.CreateSuccess(statusCode);
|
|
}
|
|
|
|
if (statusCode == 401 || statusCode == 403)
|
|
{
|
|
return OpenAIHeartbeatResult.CreateFailure(true, false, statusCode,
|
|
"Authentication failed. API key may be invalid.");
|
|
}
|
|
|
|
string errorBody = await TryReadContentAsync(response.Content).ConfigureAwait(false);
|
|
string errorMessage = string.IsNullOrEmpty(errorBody)
|
|
? string.Format("Heartbeat failed with HTTP {0}.", statusCode.ToString())
|
|
: string.Format("Heartbeat failed with HTTP {0}. body={1}", statusCode.ToString(),
|
|
errorBody);
|
|
|
|
return OpenAIHeartbeatResult.CreateFailure(true, false, statusCode, errorMessage);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
return OpenAIHeartbeatResult.CreateFailure(false, false, 0,
|
|
"Heartbeat canceled by caller.");
|
|
}
|
|
|
|
return OpenAIHeartbeatResult.CreateFailure(false, false, 0, "Heartbeat timeout.");
|
|
}
|
|
catch (HttpRequestException exception)
|
|
{
|
|
return OpenAIHeartbeatResult.CreateFailure(false, false, 0, exception.Message);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
return OpenAIHeartbeatResult.CreateFailure(false, false, 0, exception.Message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task<OpenAIStreamResult> StreamChatAsync(
|
|
string url,
|
|
string apiKey,
|
|
string requestBodyJson,
|
|
int timeoutSeconds,
|
|
Action<string> onSseDataPayload,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(url))
|
|
{
|
|
return OpenAIStreamResult.CreateFailure(false, false, 0, "AI chat request failed. URL is empty.",
|
|
string.Empty);
|
|
}
|
|
|
|
using (HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, url))
|
|
{
|
|
ApplyAuthorization(request, apiKey);
|
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
|
request.Content = new StringContent(requestBodyJson ?? string.Empty, Encoding.UTF8, "application/json");
|
|
|
|
using (CancellationTokenSource timeoutCts = new CancellationTokenSource(
|
|
TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds))))
|
|
{
|
|
using (CancellationTokenSource linkedCts =
|
|
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token))
|
|
{
|
|
try
|
|
{
|
|
using (HttpResponseMessage response = await _httpClient
|
|
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token)
|
|
.ConfigureAwait(false))
|
|
{
|
|
long statusCode = (long)response.StatusCode;
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
string errorBody = await TryReadContentAsync(response.Content).ConfigureAwait(false);
|
|
string errorMessage = string.Format("HTTP {0} {1}", statusCode.ToString(),
|
|
response.ReasonPhrase ?? string.Empty);
|
|
return OpenAIStreamResult.CreateFailure(true, false, statusCode, errorMessage,
|
|
errorBody);
|
|
}
|
|
|
|
using (Stream stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
|
|
using (StreamReader reader = new StreamReader(stream, Encoding.UTF8))
|
|
{
|
|
while (true)
|
|
{
|
|
string line =
|
|
await WaitReadLineAsync(reader, linkedCts.Token).ConfigureAwait(false);
|
|
if (line == null)
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (!line.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string payload = line.Substring(5).Trim();
|
|
if (string.IsNullOrEmpty(payload))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
onSseDataPayload?.Invoke(payload);
|
|
}
|
|
}
|
|
|
|
return OpenAIStreamResult.CreateSuccess(statusCode);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return OpenAIStreamResult.CreateCanceled();
|
|
}
|
|
catch (HttpRequestException exception)
|
|
{
|
|
return OpenAIStreamResult.CreateFailure(false, false, 0, exception.Message, string.Empty);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
return OpenAIStreamResult.CreateFailure(false, false, 0, exception.Message, string.Empty);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Dispose(true);
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
private static void ApplyAuthorization(HttpRequestMessage request, string apiKey)
|
|
{
|
|
if (request == null || string.IsNullOrWhiteSpace(apiKey))
|
|
{
|
|
return;
|
|
}
|
|
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
|
|
}
|
|
|
|
private static async Task<string> WaitReadLineAsync(StreamReader reader, CancellationToken cancellationToken)
|
|
{
|
|
Task<string> readLineTask = reader.ReadLineAsync();
|
|
if (readLineTask.IsCompleted || !cancellationToken.CanBeCanceled)
|
|
{
|
|
return await readLineTask.ConfigureAwait(false);
|
|
}
|
|
|
|
TaskCompletionSource<bool> cancellationTaskSource = new TaskCompletionSource<bool>();
|
|
using (cancellationToken.Register(() => cancellationTaskSource.TrySetResult(true)))
|
|
{
|
|
Task completedTask = await Task.WhenAny(readLineTask, cancellationTaskSource.Task).ConfigureAwait(false);
|
|
if (completedTask != readLineTask)
|
|
{
|
|
throw new OperationCanceledException(cancellationToken);
|
|
}
|
|
}
|
|
|
|
return await readLineTask.ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task<string> TryReadContentAsync(HttpContent content)
|
|
{
|
|
if (content == null)
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
try
|
|
{
|
|
return await content.ReadAsStringAsync().ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
return string.Empty;
|
|
}
|
|
}
|
|
|
|
private void Dispose(bool disposing)
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (disposing)
|
|
{
|
|
_httpClient.Dispose();
|
|
}
|
|
|
|
_disposed = true;
|
|
}
|
|
}
|
|
|
|
public sealed class OpenAIHeartbeatResult
|
|
{
|
|
public bool IsEndpointReachable { get; private set; }
|
|
public bool IsConnectionValid { get; private set; }
|
|
public long StatusCode { get; private set; }
|
|
public string ErrorMessage { get; private set; }
|
|
|
|
public static OpenAIHeartbeatResult CreateSuccess(long statusCode)
|
|
{
|
|
return new OpenAIHeartbeatResult
|
|
{
|
|
IsEndpointReachable = true,
|
|
IsConnectionValid = true,
|
|
StatusCode = statusCode,
|
|
ErrorMessage = string.Empty
|
|
};
|
|
}
|
|
|
|
public static OpenAIHeartbeatResult CreateFailure(bool endpointReachable, bool connectionValid, long statusCode,
|
|
string errorMessage)
|
|
{
|
|
return new OpenAIHeartbeatResult
|
|
{
|
|
IsEndpointReachable = endpointReachable,
|
|
IsConnectionValid = connectionValid,
|
|
StatusCode = statusCode,
|
|
ErrorMessage = errorMessage ?? string.Empty
|
|
};
|
|
}
|
|
}
|
|
|
|
public sealed class OpenAIStreamResult
|
|
{
|
|
public bool IsSuccess { get; private set; }
|
|
public bool IsCanceled { get; private set; }
|
|
public bool IsEndpointReachable { get; private set; }
|
|
public bool IsConnectionValid { get; private set; }
|
|
public long StatusCode { get; private set; }
|
|
public string ErrorMessage { get; private set; }
|
|
public string ErrorBody { get; private set; }
|
|
|
|
public static OpenAIStreamResult CreateSuccess(long statusCode)
|
|
{
|
|
return new OpenAIStreamResult
|
|
{
|
|
IsSuccess = true,
|
|
IsCanceled = false,
|
|
IsEndpointReachable = true,
|
|
IsConnectionValid = true,
|
|
StatusCode = statusCode,
|
|
ErrorMessage = string.Empty,
|
|
ErrorBody = string.Empty
|
|
};
|
|
}
|
|
|
|
public static OpenAIStreamResult CreateCanceled()
|
|
{
|
|
return new OpenAIStreamResult
|
|
{
|
|
IsSuccess = false,
|
|
IsCanceled = true,
|
|
IsEndpointReachable = false,
|
|
IsConnectionValid = false,
|
|
StatusCode = 0,
|
|
ErrorMessage = "Request canceled.",
|
|
ErrorBody = string.Empty
|
|
};
|
|
}
|
|
|
|
public static OpenAIStreamResult CreateFailure(bool endpointReachable, bool connectionValid, long statusCode,
|
|
string errorMessage, string errorBody)
|
|
{
|
|
return new OpenAIStreamResult
|
|
{
|
|
IsSuccess = false,
|
|
IsCanceled = false,
|
|
IsEndpointReachable = endpointReachable,
|
|
IsConnectionValid = connectionValid,
|
|
StatusCode = statusCode,
|
|
ErrorMessage = errorMessage ?? string.Empty,
|
|
ErrorBody = errorBody ?? string.Empty
|
|
};
|
|
}
|
|
}
|
|
}
|