feat: Thor搭建
This commit is contained in:
@@ -1,31 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.AiChat;
|
||||
|
||||
public class ChatErrorException : Exception
|
||||
{
|
||||
public string? Code { get; set; }
|
||||
|
||||
public string? Details { get; set; }
|
||||
|
||||
public LogLevel LogLevel { get; set; }
|
||||
|
||||
public ChatErrorException(
|
||||
string? code = null,
|
||||
string? message = null,
|
||||
string? details = null,
|
||||
Exception? innerException = null,
|
||||
LogLevel logLevel = LogLevel.Warning)
|
||||
: base(message, innerException)
|
||||
{
|
||||
this.Code = code;
|
||||
this.Details = details;
|
||||
this.LogLevel = logLevel;
|
||||
}
|
||||
|
||||
public ChatErrorException WithData(string name, object value)
|
||||
{
|
||||
this.Data[(object)name] = value;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using OpenAI.Chat;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.AiChat.Impl;
|
||||
|
||||
public class AzureChatService : IChatService
|
||||
{
|
||||
public AzureChatService()
|
||||
{
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<CompleteChatResponse> CompleteChatStreamAsync(AiModelDescribe aiModelDescribe,
|
||||
List<ChatMessage> messages,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var endpoint = new Uri(aiModelDescribe.Endpoint);
|
||||
|
||||
var deploymentName = aiModelDescribe.ModelId;
|
||||
var apiKey = aiModelDescribe.ApiKey;
|
||||
|
||||
AzureOpenAIClient azureClient = new(
|
||||
endpoint,
|
||||
new AzureKeyCredential(apiKey), new AzureOpenAIClientOptions()
|
||||
{
|
||||
NetworkTimeout = TimeSpan.FromSeconds(600),
|
||||
});
|
||||
|
||||
ChatClient chatClient = azureClient.GetChatClient(deploymentName);
|
||||
|
||||
var response = chatClient.CompleteChatStreamingAsync(messages, new ChatCompletionOptions()
|
||||
{
|
||||
// MaxOutputTokenCount = 2048
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
await foreach (StreamingChatCompletionUpdate update in response)
|
||||
{
|
||||
var result = new CompleteChatResponse();
|
||||
var isFinish = update.Usage?.OutputTokenCount is not null;
|
||||
if (isFinish)
|
||||
{
|
||||
result.IsFinish = true;
|
||||
result.TokenUsage = new TokenUsage
|
||||
{
|
||||
OutputTokenCount = update.Usage.OutputTokenCount,
|
||||
InputTokenCount = update.Usage.InputTokenCount,
|
||||
TotalTokenCount = update.Usage.TotalTokenCount
|
||||
};
|
||||
}
|
||||
|
||||
foreach (ChatMessageContentPart updatePart in update.ContentUpdate)
|
||||
{
|
||||
result.Content = updatePart.Text;
|
||||
yield return result;
|
||||
}
|
||||
|
||||
if (isFinish)
|
||||
{
|
||||
yield return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CompleteChatResponse> CompleteChatAsync(AiModelDescribe aiModelDescribe,
|
||||
List<ChatMessage> messages, CancellationToken cancellationToken)
|
||||
{
|
||||
var endpoint = new Uri(aiModelDescribe.Endpoint);
|
||||
|
||||
var deploymentName = aiModelDescribe.ModelId;
|
||||
var apiKey = aiModelDescribe.ApiKey;
|
||||
|
||||
AzureOpenAIClient azureClient = new(
|
||||
endpoint,
|
||||
new AzureKeyCredential(apiKey), new AzureOpenAIClientOptions()
|
||||
{
|
||||
NetworkTimeout = TimeSpan.FromSeconds(600),
|
||||
});
|
||||
|
||||
ChatClient chatClient = azureClient.GetChatClient(deploymentName);
|
||||
|
||||
var response = await chatClient.CompleteChatAsync(messages, new ChatCompletionOptions()
|
||||
{
|
||||
// MaxOutputTokenCount = 2048
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
var output = new CompleteChatResponse
|
||||
{
|
||||
TokenUsage = new TokenUsage()
|
||||
{
|
||||
OutputTokenCount = response?.Value.Usage?.OutputTokenCount ?? 0,
|
||||
InputTokenCount = response?.Value.Usage?.InputTokenCount ?? 0,
|
||||
TotalTokenCount = response?.Value.Usage?.TotalTokenCount ?? 0
|
||||
},
|
||||
IsFinish = true,
|
||||
Content = response?.Value.Content.FirstOrDefault()?.Text
|
||||
};
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using OpenAI.Chat;
|
||||
using Yi.Framework.AiHub.Domain.Extensions;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.AiChat.Impl;
|
||||
|
||||
public class AzureRestChatService : IChatService
|
||||
{
|
||||
public AzureRestChatService()
|
||||
{
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<CompleteChatResponse> CompleteChatStreamAsync(AiModelDescribe aiModelDescribe,
|
||||
List<ChatMessage> messages,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
// 设置API URL
|
||||
var apiUrl = $"{aiModelDescribe.Endpoint}";
|
||||
|
||||
// 准备请求内容
|
||||
var requestBody = new
|
||||
{
|
||||
messages = messages.Select(x => new
|
||||
{
|
||||
role = x.GetRoleAsString(),
|
||||
content = x.Content.FirstOrDefault()?.Text
|
||||
}).ToList(),
|
||||
stream = true,
|
||||
// max_tokens = 2048,
|
||||
// temperature = 0.8,
|
||||
// top_p = 0.1,
|
||||
// presence_penalty = 0,
|
||||
// frequency_penalty = 0,
|
||||
model = aiModelDescribe.ModelId
|
||||
};
|
||||
|
||||
// 序列化请求内容为JSON
|
||||
string jsonBody = JsonConvert.SerializeObject(requestBody);
|
||||
|
||||
using var httpClient = new HttpClient()
|
||||
{
|
||||
//10分钟超时
|
||||
Timeout = TimeSpan.FromSeconds(600)
|
||||
};
|
||||
// 设置请求头
|
||||
httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {aiModelDescribe.ApiKey}");
|
||||
// 其他头信息如Content-Type在StringContent中设置
|
||||
|
||||
// 构造 POST 请求
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, apiUrl);
|
||||
|
||||
// 设置请求内容(示例)
|
||||
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
|
||||
|
||||
// 发送POST请求
|
||||
HttpResponseMessage response =
|
||||
await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new UserFriendlyException(
|
||||
$"当前模型不可用:{aiModelDescribe.ModelId},状态码:{response.StatusCode},原因:{response.ReasonPhrase}");
|
||||
}
|
||||
// 确认响应成功
|
||||
// response.EnsureSuccessStatusCode();
|
||||
|
||||
// 读取响应内容
|
||||
var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
// 从流中读取数据并输出到控制台
|
||||
using var streamReader = new StreamReader(responseStream);
|
||||
while (await streamReader.ReadLineAsync(cancellationToken) is { } line)
|
||||
{
|
||||
var result = new CompleteChatResponse();
|
||||
try
|
||||
{
|
||||
var jsonObj = MapToStreamJObject(line);
|
||||
if (jsonObj is not null)
|
||||
{
|
||||
var content = GetStreamContent(jsonObj);
|
||||
var tokenUsage = GetStreamTokenUsage(jsonObj);
|
||||
result = new CompleteChatResponse
|
||||
{
|
||||
TokenUsage = tokenUsage,
|
||||
IsFinish = tokenUsage is not null,
|
||||
Content = content
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine("解析失败");
|
||||
}
|
||||
|
||||
yield return result;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CompleteChatResponse> CompleteChatAsync(AiModelDescribe aiModelDescribe,
|
||||
List<ChatMessage> messages, CancellationToken cancellationToken)
|
||||
{
|
||||
// 设置API URL
|
||||
var apiUrl = $"{aiModelDescribe.Endpoint}";
|
||||
|
||||
// 准备请求内容
|
||||
var requestBody = new
|
||||
{
|
||||
messages = messages.Select(x => new
|
||||
{
|
||||
role = x.GetRoleAsString(),
|
||||
content = x.Content.FirstOrDefault()?.Text
|
||||
}).ToList(),
|
||||
stream = false,
|
||||
// max_tokens = 2048,
|
||||
// temperature = 0.8,
|
||||
// top_p = 0.1,
|
||||
// presence_penalty = 0,
|
||||
// frequency_penalty = 0,
|
||||
model = aiModelDescribe.ModelId
|
||||
};
|
||||
|
||||
// 序列化请求内容为JSON
|
||||
string jsonBody = JsonConvert.SerializeObject(requestBody);
|
||||
|
||||
using var httpClient = new HttpClient()
|
||||
{
|
||||
//10分钟超时
|
||||
Timeout = TimeSpan.FromSeconds(600)
|
||||
};
|
||||
// 设置请求头
|
||||
httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {aiModelDescribe.ApiKey}");
|
||||
// 其他头信息如Content-Type在StringContent中设置
|
||||
|
||||
// 构造 POST 请求
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, apiUrl);
|
||||
|
||||
// 设置请求内容(示例)
|
||||
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
|
||||
|
||||
// 发送POST请求
|
||||
HttpResponseMessage response =
|
||||
await httpClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new UserFriendlyException(
|
||||
$"当前模型不可用:{aiModelDescribe.ModelId},状态码:{response.StatusCode},原因:{response.ReasonPhrase}");
|
||||
}
|
||||
// 确认响应成功
|
||||
// response.EnsureSuccessStatusCode();
|
||||
|
||||
// 读取响应内容
|
||||
var responseStr = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var jObject = MapToJObject(responseStr);
|
||||
|
||||
var content = GetContent(jObject);
|
||||
var usage = GetTokenUsage(jObject);
|
||||
var result = new CompleteChatResponse
|
||||
{
|
||||
TokenUsage = usage,
|
||||
IsFinish = true,
|
||||
Content = content
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private JObject? MapToJObject(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
return null;
|
||||
return JObject.Parse(body);
|
||||
}
|
||||
|
||||
private string? GetContent(JObject? jsonObj)
|
||||
{
|
||||
var contentToken = jsonObj.SelectToken("choices[0].message.content");
|
||||
if (contentToken != null && contentToken.Type != JTokenType.Null)
|
||||
{
|
||||
return contentToken.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private TokenUsage? GetTokenUsage(JObject? jsonObj)
|
||||
{
|
||||
var usage = jsonObj.SelectToken("usage");
|
||||
if (usage is not null && usage.Type != JTokenType.Null)
|
||||
{
|
||||
var result = new TokenUsage();
|
||||
var completionTokens = usage["completion_tokens"];
|
||||
if (completionTokens is not null && completionTokens.Type != JTokenType.Null)
|
||||
{
|
||||
result.OutputTokenCount = completionTokens.ToObject<int>();
|
||||
}
|
||||
|
||||
var promptTokens = usage["prompt_tokens"];
|
||||
if (promptTokens is not null && promptTokens.Type != JTokenType.Null)
|
||||
{
|
||||
result.InputTokenCount = promptTokens.ToObject<int>();
|
||||
}
|
||||
|
||||
var totalTokens = usage["total_tokens"];
|
||||
if (totalTokens is not null && totalTokens.Type != JTokenType.Null)
|
||||
{
|
||||
result.TotalTokenCount = totalTokens.ToObject<int>();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private JObject? MapToStreamJObject(string line)
|
||||
{
|
||||
if (line == "data: [DONE]" || string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
return null;
|
||||
string prefix = "data: ";
|
||||
line = line.Substring(prefix.Length);
|
||||
return JObject.Parse(line);
|
||||
}
|
||||
|
||||
private string? GetStreamContent(JObject? jsonObj)
|
||||
{
|
||||
var contentToken = jsonObj.SelectToken("choices[0].delta.content");
|
||||
if (contentToken != null && contentToken.Type != JTokenType.Null)
|
||||
{
|
||||
return contentToken.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private TokenUsage? GetStreamTokenUsage(JObject? jsonObj)
|
||||
{
|
||||
var usage = jsonObj.SelectToken("usage");
|
||||
if (usage is not null && usage.Type != JTokenType.Null)
|
||||
{
|
||||
var result = new TokenUsage();
|
||||
var completionTokens = usage["completion_tokens"];
|
||||
if (completionTokens is not null && completionTokens.Type != JTokenType.Null)
|
||||
{
|
||||
result.OutputTokenCount = completionTokens.ToObject<int>();
|
||||
}
|
||||
|
||||
var promptTokens = usage["prompt_tokens"];
|
||||
if (promptTokens is not null && promptTokens.Type != JTokenType.Null)
|
||||
{
|
||||
result.InputTokenCount = promptTokens.ToObject<int>();
|
||||
}
|
||||
|
||||
var totalTokens = usage["total_tokens"];
|
||||
if (totalTokens is not null && totalTokens.Type != JTokenType.Null)
|
||||
{
|
||||
result.TotalTokenCount = totalTokens.ToObject<int>();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Yi.Framework.AiHub.Domain.AiGateWay.Exceptions;
|
||||
|
||||
public sealed class PaymentRequiredException() : Exception()
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Yi.Framework.AiHub.Domain.AiGateWay.Exceptions;
|
||||
|
||||
public class ThorRateLimitException : Exception
|
||||
{
|
||||
public ThorRateLimitException()
|
||||
{
|
||||
}
|
||||
|
||||
public ThorRateLimitException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.AiGateWay;
|
||||
|
||||
public static class HttpClientExtensions
|
||||
{
|
||||
public static async Task<HttpResponseMessage> HttpRequestRaw(this HttpClient httpClient, string url,
|
||||
object? postData,
|
||||
string token)
|
||||
{
|
||||
HttpRequestMessage req = new(HttpMethod.Post, url);
|
||||
|
||||
if (postData != null)
|
||||
{
|
||||
if (postData is HttpContent data)
|
||||
{
|
||||
req.Content = data;
|
||||
}
|
||||
else
|
||||
{
|
||||
string jsonContent = JsonSerializer.Serialize(postData, ThorJsonSerializer.DefaultOptions);
|
||||
var stringContent = new StringContent(jsonContent, Encoding.UTF8, "application/json");
|
||||
req.Content = stringContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
req.Headers.Add("Authorization", $"Bearer {token}");
|
||||
}
|
||||
|
||||
var response = await httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> HttpRequestRaw(this HttpClient httpClient, string url,
|
||||
object? postData,
|
||||
string token, string tokenKey)
|
||||
{
|
||||
HttpRequestMessage req = new(HttpMethod.Post, url);
|
||||
|
||||
if (postData != null)
|
||||
{
|
||||
if (postData is HttpContent data)
|
||||
{
|
||||
req.Content = data;
|
||||
}
|
||||
else
|
||||
{
|
||||
string jsonContent = JsonSerializer.Serialize(postData, ThorJsonSerializer.DefaultOptions);
|
||||
var stringContent = new StringContent(jsonContent, Encoding.UTF8, "application/json");
|
||||
req.Content = stringContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
req.Headers.Add(tokenKey, token);
|
||||
}
|
||||
|
||||
|
||||
var response = await httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> HttpRequestRaw(this HttpClient httpClient, string url,
|
||||
object? postData,
|
||||
string token, Dictionary<string, string> headers)
|
||||
{
|
||||
HttpRequestMessage req = new(HttpMethod.Post, url);
|
||||
|
||||
if (postData != null)
|
||||
{
|
||||
if (postData is HttpContent data)
|
||||
{
|
||||
req.Content = data;
|
||||
}
|
||||
else
|
||||
{
|
||||
string jsonContent = JsonSerializer.Serialize(postData, ThorJsonSerializer.DefaultOptions);
|
||||
var stringContent = new StringContent(jsonContent, Encoding.UTF8, "application/json");
|
||||
req.Content = stringContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
req.Headers.Add("Authorization", $"Bearer {token}");
|
||||
}
|
||||
|
||||
foreach (var header in headers)
|
||||
{
|
||||
req.Headers.Add(header.Key, header.Value);
|
||||
}
|
||||
|
||||
|
||||
var response = await httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> HttpRequestRaw(this HttpClient httpClient, HttpRequestMessage req,
|
||||
object? postData)
|
||||
{
|
||||
if (postData != null)
|
||||
{
|
||||
if (postData is HttpContent data)
|
||||
{
|
||||
req.Content = data;
|
||||
}
|
||||
else
|
||||
{
|
||||
string jsonContent = JsonSerializer.Serialize(postData, ThorJsonSerializer.DefaultOptions);
|
||||
var stringContent = new StringContent(jsonContent, Encoding.UTF8, "application/json");
|
||||
req.Content = stringContent;
|
||||
}
|
||||
}
|
||||
|
||||
var response = await httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> PostJsonAsync(this HttpClient httpClient, string url,
|
||||
object? postData,
|
||||
string token)
|
||||
{
|
||||
HttpRequestMessage req = new(HttpMethod.Post, url);
|
||||
|
||||
if (postData != null)
|
||||
{
|
||||
if (postData is HttpContent data)
|
||||
{
|
||||
req.Content = data;
|
||||
}
|
||||
else
|
||||
{
|
||||
var stringContent =
|
||||
new StringContent(JsonSerializer.Serialize(postData, ThorJsonSerializer.DefaultOptions),
|
||||
Encoding.UTF8, "application/json");
|
||||
req.Content = stringContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
req.Headers.Add("Authorization", $"Bearer {token}");
|
||||
}
|
||||
|
||||
return await httpClient.SendAsync(req);
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> PostJsonAsync(this HttpClient httpClient, string url,
|
||||
object? postData,
|
||||
string token, Dictionary<string, string> headers)
|
||||
{
|
||||
HttpRequestMessage req = new(HttpMethod.Post, url);
|
||||
|
||||
if (postData != null)
|
||||
{
|
||||
if (postData is HttpContent data)
|
||||
{
|
||||
req.Content = data;
|
||||
}
|
||||
else
|
||||
{
|
||||
string jsonContent = JsonSerializer.Serialize(postData, ThorJsonSerializer.DefaultOptions);
|
||||
var stringContent = new StringContent(jsonContent, Encoding.UTF8, "application/json");
|
||||
req.Content = stringContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
req.Headers.Add("Authorization", $"Bearer {token}");
|
||||
}
|
||||
|
||||
foreach (var header in headers)
|
||||
{
|
||||
req.Headers.Add(header.Key, header.Value);
|
||||
}
|
||||
|
||||
return await httpClient.SendAsync(req);
|
||||
}
|
||||
|
||||
public static Task<HttpResponseMessage> PostJsonAsync(this HttpClient httpClient, string url, object? postData,
|
||||
string token, string tokenKey)
|
||||
{
|
||||
HttpRequestMessage req = new(HttpMethod.Post, url);
|
||||
|
||||
if (postData != null)
|
||||
{
|
||||
if (postData is HttpContent data)
|
||||
{
|
||||
req.Content = data;
|
||||
}
|
||||
else
|
||||
{
|
||||
string jsonContent = JsonSerializer.Serialize(postData, ThorJsonSerializer.DefaultOptions);
|
||||
var stringContent = new StringContent(jsonContent, Encoding.UTF8, "application/json");
|
||||
req.Content = stringContent;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
req.Headers.Add(tokenKey, token);
|
||||
}
|
||||
|
||||
return httpClient.SendAsync(req);
|
||||
}
|
||||
|
||||
|
||||
public static async Task<TResponse> PostAndReadAsAsync<TResponse>(this HttpClient client, string uri,
|
||||
object? requestModel, CancellationToken cancellationToken = default) where TResponse : ThorBaseResponse, new()
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(uri, requestModel, new JsonSerializerOptions
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault
|
||||
}, cancellationToken);
|
||||
return await HandleResponseContent<TResponse>(response, cancellationToken);
|
||||
}
|
||||
|
||||
public static async Task<TResponse> PostFileAndReadAsAsync<TResponse>(this HttpClient client, string uri,
|
||||
HttpContent content, CancellationToken cancellationToken = default) where TResponse : ThorBaseResponse, new()
|
||||
{
|
||||
var response = await client.PostAsync(uri, content, cancellationToken);
|
||||
return await HandleResponseContent<TResponse>(response, cancellationToken);
|
||||
}
|
||||
|
||||
public static async Task<string> PostFileAndReadAsStringAsync(this HttpClient client, string uri,
|
||||
HttpContent content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await client.PostAsync(uri, content, cancellationToken);
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken) ?? throw new InvalidOperationException();
|
||||
}
|
||||
|
||||
public static async Task<TResponse> DeleteAndReadAsAsync<TResponse>(this HttpClient client, string uri,
|
||||
CancellationToken cancellationToken = default) where TResponse : ThorBaseResponse, new()
|
||||
{
|
||||
var response = await client.DeleteAsync(uri, cancellationToken);
|
||||
return await HandleResponseContent<TResponse>(response, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<TResponse> HandleResponseContent<TResponse>(this HttpResponseMessage response,
|
||||
CancellationToken cancellationToken) where TResponse : ThorBaseResponse, new()
|
||||
{
|
||||
TResponse result;
|
||||
|
||||
if (!response.Content.Headers.ContentType?.MediaType?.Equals("application/json",
|
||||
StringComparison.OrdinalIgnoreCase) ?? true)
|
||||
{
|
||||
result = new()
|
||||
{
|
||||
Error = new()
|
||||
{
|
||||
MessageObject = await response.Content.ReadAsStringAsync(cancellationToken)
|
||||
}
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await response.Content.ReadFromJsonAsync<TResponse>(cancellationToken: cancellationToken) ??
|
||||
throw new InvalidOperationException();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.AiGateWay;
|
||||
|
||||
public static class HttpClientFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// HttpClient池总数
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
private static int _poolSize;
|
||||
|
||||
private static int PoolSize
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_poolSize == 0)
|
||||
{
|
||||
// 获取环境变量
|
||||
var poolSize = Environment.GetEnvironmentVariable("HttpClientPoolSize");
|
||||
if (!string.IsNullOrEmpty(poolSize) && int.TryParse(poolSize, out var size))
|
||||
{
|
||||
_poolSize = size;
|
||||
}
|
||||
else
|
||||
{
|
||||
_poolSize = Environment.ProcessorCount;
|
||||
}
|
||||
|
||||
if (_poolSize < 1)
|
||||
{
|
||||
_poolSize = 2;
|
||||
}
|
||||
}
|
||||
|
||||
return _poolSize;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly ConcurrentDictionary<string, Lazy<List<HttpClient>>> HttpClientPool = new();
|
||||
|
||||
public static HttpClient GetHttpClient(string key)
|
||||
{
|
||||
return HttpClientPool.GetOrAdd(key, k => new Lazy<List<HttpClient>>(() =>
|
||||
{
|
||||
var clients = new List<HttpClient>(PoolSize);
|
||||
|
||||
for (var i = 0; i < PoolSize; i++)
|
||||
{
|
||||
clients.Add(new HttpClient(new SocketsHttpHandler
|
||||
{
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(30),
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(30),
|
||||
EnableMultipleHttp2Connections = true,
|
||||
// 连接超时5分钟
|
||||
ConnectTimeout = TimeSpan.FromMinutes(5),
|
||||
MaxAutomaticRedirections = 3,
|
||||
AllowAutoRedirect = true,
|
||||
Expect100ContinueTimeout = TimeSpan.FromMinutes(30),
|
||||
})
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(30),
|
||||
DefaultRequestHeaders =
|
||||
{
|
||||
{ "User-Agent", "yxai" },
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return clients;
|
||||
})).Value[new Random().Next(0, PoolSize)];
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,29 @@
|
||||
using OpenAI.Chat;
|
||||
using Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.AiChat;
|
||||
namespace Yi.Framework.AiHub.Domain.AiGateWay;
|
||||
|
||||
public interface IChatService
|
||||
public interface IChatCompletionService
|
||||
{
|
||||
/// <summary>
|
||||
/// 聊天完成-流式
|
||||
/// </summary>
|
||||
/// <param name="aiModelDescribe"></param>
|
||||
/// <param name="messages"></param>
|
||||
/// <param name="input"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public IAsyncEnumerable<CompleteChatResponse> CompleteChatStreamAsync(AiModelDescribe aiModelDescribe, List<ChatMessage> messages,
|
||||
public IAsyncEnumerable<ThorChatCompletionsResponse> CompleteChatStreamAsync(AiModelDescribe aiModelDescribe,
|
||||
ThorChatCompletionsRequest input,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 聊天完成-非流式
|
||||
/// </summary>
|
||||
/// <param name="aiModelDescribe"></param>
|
||||
/// <param name="messages"></param>
|
||||
/// <param name="input"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public Task<CompleteChatResponse> CompleteChatAsync(AiModelDescribe aiModelDescribe, List<ChatMessage> messages,
|
||||
public Task<ThorChatCompletionsResponse> CompleteChatAsync(AiModelDescribe aiModelDescribe,
|
||||
ThorChatCompletionsRequest input,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
|
||||
using Yi.Framework.AiHub.Domain.AiGateWay.Exceptions;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorAzureDatabricks.Chats;
|
||||
|
||||
public class AzureDatabricksChatCompletionsService(ILogger<AzureDatabricksChatCompletionsService> logger)
|
||||
: IChatCompletionService
|
||||
{
|
||||
private string GetAddress(AiModelDescribe? options, string model)
|
||||
{
|
||||
// This method should return the appropriate URL for the Azure Databricks API
|
||||
// based on the provided options and model.
|
||||
// For now, we will return a placeholder URL.
|
||||
return $"{options?.Endpoint.TrimEnd('/')}/serving-endpoints/{model}/invocations";
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ThorChatCompletionsResponse> CompleteChatStreamAsync(AiModelDescribe options, ThorChatCompletionsRequest chatCompletionCreate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var address = GetAddress(options, chatCompletionCreate.Model);
|
||||
using var openai =
|
||||
Activity.Current?.Source.StartActivity("OpenAI 对话流式补全");
|
||||
|
||||
chatCompletionCreate.StreamOptions = null;
|
||||
|
||||
var response = await HttpClientFactory.GetHttpClient(address).HttpRequestRaw(
|
||||
address,
|
||||
chatCompletionCreate, options.ApiKey);
|
||||
|
||||
openai?.SetTag("Model", chatCompletionCreate.Model);
|
||||
openai?.SetTag("Response", response.StatusCode.ToString());
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.PaymentRequired)
|
||||
{
|
||||
throw new PaymentRequiredException();
|
||||
}
|
||||
|
||||
// 如果限流则抛出限流异常
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
throw new ThorRateLimitException();
|
||||
}
|
||||
|
||||
// 大于等于400的状态码都认为是异常
|
||||
if (response.StatusCode >= HttpStatusCode.BadRequest)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
logger.LogError("OpenAI对话异常 , StatusCode: {StatusCode} 错误响应内容:{Content}", response.StatusCode,
|
||||
error);
|
||||
|
||||
throw new BusinessException("OpenAI对话异常:" + error, response.StatusCode.ToString());
|
||||
}
|
||||
|
||||
using var stream = new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken));
|
||||
|
||||
using StreamReader reader = new(await response.Content.ReadAsStreamAsync(cancellationToken));
|
||||
string? line = string.Empty;
|
||||
var first = true;
|
||||
var isThink = false;
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) != null)
|
||||
{
|
||||
line += Environment.NewLine;
|
||||
|
||||
if (line.StartsWith('{'))
|
||||
{
|
||||
logger.LogInformation("OpenAI对话异常 , StatusCode: {StatusCode} Response: {Response}", response.StatusCode,
|
||||
line);
|
||||
|
||||
throw new BusinessException("OpenAI对话异常", line);
|
||||
}
|
||||
|
||||
if (line.StartsWith(OpenAIConstant.Data))
|
||||
line = line[OpenAIConstant.Data.Length..];
|
||||
|
||||
line = line.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
|
||||
if (line == OpenAIConstant.Done)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (line.StartsWith(':'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
var result = JsonSerializer.Deserialize<ThorChatCompletionsResponse>(line,
|
||||
ThorJsonSerializer.DefaultOptions);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var content = result?.Choices?.FirstOrDefault()?.Delta;
|
||||
|
||||
if (first && content?.Content == OpenAIConstant.ThinkStart)
|
||||
{
|
||||
isThink = true;
|
||||
continue;
|
||||
// 需要将content的内容转换到其他字段
|
||||
}
|
||||
|
||||
if (isThink && content?.Content?.Contains(OpenAIConstant.ThinkEnd) == true)
|
||||
{
|
||||
isThink = false;
|
||||
// 需要将content的内容转换到其他字段
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isThink && result?.Choices != null)
|
||||
{
|
||||
// 需要将content的内容转换到其他字段
|
||||
foreach (var choice in result.Choices)
|
||||
{
|
||||
choice.Delta.ReasoningContent = choice.Delta.Content;
|
||||
choice.Delta.Content = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
first = false;
|
||||
|
||||
yield return result;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ThorChatCompletionsResponse> CompleteChatAsync(AiModelDescribe options, ThorChatCompletionsRequest chatCompletionCreate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var address = GetAddress(options, chatCompletionCreate.Model);
|
||||
|
||||
using var openai =
|
||||
Activity.Current?.Source.StartActivity("OpenAI 对话补全");
|
||||
|
||||
var response = await HttpClientFactory.GetHttpClient(address).PostJsonAsync(
|
||||
address,
|
||||
chatCompletionCreate, options.ApiKey).ConfigureAwait(false);
|
||||
|
||||
openai?.SetTag("Model", chatCompletionCreate.Model);
|
||||
openai?.SetTag("Response", response.StatusCode.ToString());
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
throw new BusinessException("渠道未登录,请联系管理人员", "401");
|
||||
}
|
||||
|
||||
// 如果限流则抛出限流异常
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
throw new ThorRateLimitException();
|
||||
}
|
||||
|
||||
// 大于等于400的状态码都认为是异常
|
||||
if (response.StatusCode >= HttpStatusCode.BadRequest)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError("OpenAI对话异常 请求地址:{Address}, StatusCode: {StatusCode} Response: {Response}", options.Endpoint,
|
||||
response.StatusCode, error);
|
||||
|
||||
throw new BusinessException("OpenAI对话异常", response.StatusCode.ToString());
|
||||
}
|
||||
|
||||
var result =
|
||||
await response.Content.ReadFromJsonAsync<ThorChatCompletionsResponse>(
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.ClientModel;
|
||||
using System.Collections.Concurrent;
|
||||
using Azure.AI.OpenAI;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorAzureOpenAI;
|
||||
|
||||
public static class AzureOpenAIFactory
|
||||
{
|
||||
private const string AddressTemplate = "{0}/openai/deployments/{1}/chat/completions?api-version={2}";
|
||||
private const string EditImageAddressTemplate = "{0}/openai/deployments/{1}/images/edits?api-version={2}";
|
||||
private const string AudioSpeechTemplate = "{0}/openai/deployments/{1}/audio/speech?api-version={2}";
|
||||
|
||||
private const string AudioTranscriptions =
|
||||
"{0}/openai/deployments/{1}/audio/transcriptions?api-version={2}";
|
||||
|
||||
private static readonly ConcurrentDictionary<string, AzureOpenAIClient> Clients = new();
|
||||
|
||||
public static string GetAudioTranscriptionsAddress(AiModelDescribe options, string model)
|
||||
{
|
||||
if (string.IsNullOrEmpty(options.ExtraUrl))
|
||||
{
|
||||
options.ExtraUrl = "2025-03-01-preview";
|
||||
}
|
||||
|
||||
return string.Format(AudioTranscriptions, options.Endpoint.TrimEnd('/'), model, options.ExtraUrl);
|
||||
}
|
||||
|
||||
public static string GetAudioSpeechAddress(AiModelDescribe options, string model)
|
||||
{
|
||||
if (string.IsNullOrEmpty(options.ExtraUrl))
|
||||
{
|
||||
options.ExtraUrl = "2025-03-01-preview";
|
||||
}
|
||||
|
||||
return string.Format(AudioSpeechTemplate, options.Endpoint.TrimEnd('/'), model, options.ExtraUrl);
|
||||
}
|
||||
|
||||
public static string GetAddress(AiModelDescribe options, string model)
|
||||
{
|
||||
if (string.IsNullOrEmpty(options.ExtraUrl))
|
||||
{
|
||||
options.ExtraUrl = "2025-03-01-preview";
|
||||
}
|
||||
|
||||
return string.Format(AddressTemplate, options.Endpoint.TrimEnd('/'), model, options.ExtraUrl);
|
||||
}
|
||||
|
||||
public static string GetEditImageAddress(AiModelDescribe options, string model)
|
||||
{
|
||||
if (string.IsNullOrEmpty(options.ExtraUrl))
|
||||
{
|
||||
options.ExtraUrl = "2025-03-01-preview";
|
||||
}
|
||||
|
||||
return string.Format(EditImageAddressTemplate, options.Endpoint.TrimEnd('/'), model, options.ExtraUrl);
|
||||
}
|
||||
|
||||
public static AzureOpenAIClient CreateClient(AiModelDescribe options)
|
||||
{
|
||||
return Clients.GetOrAdd($"{options.ApiKey}_{options.Endpoint}_{options.ExtraUrl}", (_) =>
|
||||
{
|
||||
const AzureOpenAIClientOptions.ServiceVersion version = AzureOpenAIClientOptions.ServiceVersion.V2024_06_01;
|
||||
|
||||
var client = new AzureOpenAIClient(new Uri(options.Endpoint), new ApiKeyCredential(options.ApiKey),
|
||||
new AzureOpenAIClientOptions(version));
|
||||
|
||||
return client;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
|
||||
using Yi.Framework.AiHub.Domain.AiGateWay.Exceptions;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorAzureOpenAI.Chats;
|
||||
|
||||
public class AzureOpenAiChatCompletionCompletionsService(ILogger<AzureOpenAiChatCompletionCompletionsService> logger)
|
||||
: IChatCompletionService
|
||||
{
|
||||
public async IAsyncEnumerable<ThorChatCompletionsResponse> CompleteChatStreamAsync(AiModelDescribe options,
|
||||
ThorChatCompletionsRequest chatCompletionCreate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var openai =
|
||||
Activity.Current?.Source.StartActivity("Azure OpenAI 对话流式补全");
|
||||
var url = AzureOpenAIFactory.GetAddress(options, chatCompletionCreate.Model);
|
||||
|
||||
var response = await HttpClientFactory.GetHttpClient(options.Endpoint).HttpRequestRaw(url,
|
||||
chatCompletionCreate, options.ApiKey, "Api-Key");
|
||||
|
||||
openai?.SetTag("Model", chatCompletionCreate.Model);
|
||||
openai?.SetTag("Response", response.StatusCode.ToString());
|
||||
|
||||
if (response.StatusCode >= HttpStatusCode.BadRequest)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync();
|
||||
logger.LogError("Azure对话异常 , StatusCode: {StatusCode} 错误响应内容:{Content}", response.StatusCode,
|
||||
error);
|
||||
|
||||
throw new BusinessException("AzureOpenAI对话异常:" + error, response.StatusCode.ToString());
|
||||
}
|
||||
|
||||
using StreamReader reader = new(await response.Content.ReadAsStreamAsync(cancellationToken));
|
||||
string? line = string.Empty;
|
||||
var first = true;
|
||||
while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null)
|
||||
{
|
||||
line += Environment.NewLine;
|
||||
|
||||
if (line.StartsWith('{'))
|
||||
{
|
||||
logger.LogInformation("AzureOpenAI对话异常 , StatusCode: {StatusCode} Response: {Response}",
|
||||
response.StatusCode,
|
||||
line);
|
||||
|
||||
throw new BusinessException("AzureOpenAI对话异常", line);
|
||||
}
|
||||
|
||||
if (line.StartsWith(OpenAIConstant.Data))
|
||||
line = line[OpenAIConstant.Data.Length..];
|
||||
|
||||
line = line.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
|
||||
if (line == OpenAIConstant.Done)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (line.StartsWith(':'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var result = JsonSerializer.Deserialize<ThorChatCompletionsResponse>(line,
|
||||
ThorJsonSerializer.DefaultOptions);
|
||||
|
||||
yield return result;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ThorChatCompletionsResponse> CompleteChatAsync(AiModelDescribe options,
|
||||
ThorChatCompletionsRequest chatCompletionCreate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var openai =
|
||||
Activity.Current?.Source.StartActivity("Azure OpenAI 对话补全");
|
||||
var url = AzureOpenAIFactory.GetAddress(options, chatCompletionCreate.Model);
|
||||
|
||||
var response =
|
||||
await HttpClientFactory.GetHttpClient(options.Endpoint)
|
||||
.PostJsonAsync(url, chatCompletionCreate, options.ApiKey, "Api-Key");
|
||||
|
||||
openai?.SetTag("Model", chatCompletionCreate.Model);
|
||||
openai?.SetTag("Response", response.StatusCode.ToString());
|
||||
// 如果限流则抛出限流异常
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
throw new ThorRateLimitException();
|
||||
}
|
||||
|
||||
if (response.StatusCode >= HttpStatusCode.BadRequest)
|
||||
{
|
||||
logger.LogError("Azure对话异常 , StatusCode: {StatusCode} Response: {Response} Url:{Url}", response.StatusCode,
|
||||
await response.Content.ReadAsStringAsync(cancellationToken), url);
|
||||
}
|
||||
|
||||
var result = await response.Content
|
||||
.ReadFromJsonAsync<ThorChatCompletionsResponse>(ThorJsonSerializer.DefaultOptions,
|
||||
cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.AiGateWay;
|
||||
|
||||
public record ThorBaseResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 对象类型
|
||||
/// </summary>
|
||||
[JsonPropertyName("object")]
|
||||
public string? ObjectTypeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public bool Successful => Error == null;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public ThorError? Error { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.AiGateWay;
|
||||
|
||||
public static class ThorJsonSerializer
|
||||
{
|
||||
public static JsonSerializerOptions DefaultOptions => new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Mapster;
|
||||
using SqlSugar;
|
||||
using Volo.Abp.Domain.Entities.Auditing;
|
||||
using Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
|
||||
using Yi.Framework.AiHub.Domain.Entities.ValueObjects;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Enums;
|
||||
@@ -19,7 +20,7 @@ public class MessageAggregateRoot : FullAuditedAggregateRoot<Guid>
|
||||
}
|
||||
|
||||
public MessageAggregateRoot(Guid userId, Guid? sessionId, string content, string role, string modelId,
|
||||
TokenUsage? tokenUsage)
|
||||
ThorUsageResponse? tokenUsage)
|
||||
{
|
||||
UserId = userId;
|
||||
SessionId = sessionId;
|
||||
@@ -28,7 +29,12 @@ public class MessageAggregateRoot : FullAuditedAggregateRoot<Guid>
|
||||
ModelId = modelId;
|
||||
if (tokenUsage is not null)
|
||||
{
|
||||
this.TokenUsage = tokenUsage.Adapt<TokenUsageValueObject>();
|
||||
this.TokenUsage = new TokenUsageValueObject
|
||||
{
|
||||
OutputTokenCount = tokenUsage.OutputTokens ?? 0,
|
||||
InputTokenCount = tokenUsage.InputTokens ?? 0,
|
||||
TotalTokenCount = tokenUsage.TotalTokens ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
this.MessageType = sessionId is null ? MessageTypeEnum.Api : MessageTypeEnum.Web;
|
||||
|
||||
@@ -6,5 +6,5 @@ public class TokenUsageValueObject
|
||||
|
||||
public int InputTokenCount { get; set; }
|
||||
|
||||
public int TotalTokenCount { get; set; }
|
||||
public long TotalTokenCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Yi.Framework.AiHub.Domain.Extensions;
|
||||
|
||||
public static class TimeExtensions
|
||||
{
|
||||
public static long ToUnixTimeSeconds(this DateTime dateTime)
|
||||
{
|
||||
return new DateTimeOffset(dateTime).ToUnixTimeSeconds();
|
||||
}
|
||||
|
||||
public static long ToUnixTimeMilliseconds(this DateTime dateTime)
|
||||
{
|
||||
return new DateTimeOffset(dateTime).ToUnixTimeMilliseconds();
|
||||
}
|
||||
|
||||
public static DateTime FromUnixTimeSeconds(this long seconds)
|
||||
{
|
||||
return DateTimeOffset.FromUnixTimeSeconds(seconds).DateTime;
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,13 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using OpenAI.Chat;
|
||||
using Volo.Abp.Domain.Services;
|
||||
using Yi.Framework.AiHub.Application.Contracts.Dtos;
|
||||
using Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
|
||||
using Yi.Framework.AiHub.Domain.AiChat;
|
||||
using Yi.Framework.AiHub.Domain.Entities;
|
||||
using Yi.Framework.AiHub.Domain.AiGateWay;
|
||||
using Yi.Framework.AiHub.Domain.Entities.Model;
|
||||
using Yi.Framework.AiHub.Domain.Shared.Dtos;
|
||||
using Yi.Framework.SqlSugarCore.Abstractions;
|
||||
using Usage = Yi.Framework.AiHub.Application.Contracts.Dtos.Usage;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.Managers;
|
||||
|
||||
@@ -70,17 +67,16 @@ public class AiGateWayManager : DomainService
|
||||
/// <summary>
|
||||
/// 聊天完成-流式
|
||||
/// </summary>
|
||||
/// <param name="modelId"></param>
|
||||
/// <param name="messages"></param>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async IAsyncEnumerable<CompleteChatResponse> CompleteChatStreamAsync(string modelId,
|
||||
List<ChatMessage> messages,
|
||||
public async IAsyncEnumerable<ThorChatCompletionsResponse> CompleteChatStreamAsync(
|
||||
ThorChatCompletionsRequest request,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var modelDescribe = await GetModelAsync(modelId);
|
||||
var chatService = LazyServiceProvider.GetRequiredKeyedService<IChatService>(modelDescribe.HandlerName);
|
||||
await foreach (var result in chatService.CompleteChatStreamAsync(modelDescribe, messages, cancellationToken))
|
||||
var modelDescribe = await GetModelAsync(request.Model);
|
||||
var chatService = LazyServiceProvider.GetRequiredKeyedService<IChatCompletionService>(modelDescribe.HandlerName);
|
||||
await foreach (var result in chatService.CompleteChatStreamAsync(modelDescribe, request, cancellationToken))
|
||||
{
|
||||
yield return result;
|
||||
}
|
||||
@@ -91,14 +87,13 @@ public class AiGateWayManager : DomainService
|
||||
/// 聊天完成-非流式
|
||||
/// </summary>
|
||||
/// <param name="httpContext"></param>
|
||||
/// <param name="modelId"></param>
|
||||
/// <param name="messages"></param>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
public async Task CompleteChatForStatisticsAsync(HttpContext httpContext, string modelId,
|
||||
List<ChatMessage> messages,
|
||||
public async Task CompleteChatForStatisticsAsync(HttpContext httpContext,
|
||||
ThorChatCompletionsRequest request,
|
||||
Guid? userId = null,
|
||||
Guid? sessionId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -107,33 +102,32 @@ public class AiGateWayManager : DomainService
|
||||
// 设置响应头,声明是 json
|
||||
response.ContentType = "application/json; charset=UTF-8";
|
||||
await using var writer = new StreamWriter(response.Body, Encoding.UTF8, leaveOpen: true);
|
||||
var modelDescribe = await GetModelAsync(modelId);
|
||||
var chatService = LazyServiceProvider.GetRequiredKeyedService<IChatService>(modelDescribe.HandlerName);
|
||||
var data = await chatService.CompleteChatAsync(modelDescribe, messages, cancellationToken);
|
||||
var modelDescribe = await GetModelAsync(request.Model);
|
||||
var chatService = LazyServiceProvider.GetRequiredKeyedService<IChatCompletionService>(modelDescribe.HandlerName);
|
||||
var data = await chatService.CompleteChatAsync(modelDescribe, request, cancellationToken);
|
||||
if (userId is not null)
|
||||
{
|
||||
await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId,
|
||||
new MessageInputDto
|
||||
{
|
||||
Content = messages.LastOrDefault().Content.FirstOrDefault()?.Text ?? string.Empty,
|
||||
ModelId = modelId,
|
||||
TokenUsage = data.TokenUsage,
|
||||
Content = request.Messages.LastOrDefault().Content ?? string.Empty,
|
||||
ModelId = request.Model,
|
||||
TokenUsage = data.Usage,
|
||||
});
|
||||
|
||||
await _aiMessageManager.CreateSystemMessageAsync(userId.Value, sessionId,
|
||||
new MessageInputDto
|
||||
{
|
||||
Content = data.Content,
|
||||
ModelId = modelId,
|
||||
TokenUsage = data.TokenUsage
|
||||
Content = data.Choices.FirstOrDefault()?.Delta.Content,
|
||||
ModelId = request.Model,
|
||||
TokenUsage = data.Usage
|
||||
});
|
||||
|
||||
await _usageStatisticsManager.SetUsageAsync(userId.Value, modelId, data.TokenUsage.InputTokenCount,
|
||||
data.TokenUsage.OutputTokenCount);
|
||||
await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, data.Usage.InputTokens ?? 0,
|
||||
data.Usage.OutputTokens ?? 0);
|
||||
}
|
||||
|
||||
var result = MapToChatCompletions(modelId, data.Content);
|
||||
var body = JsonConvert.SerializeObject(result, new JsonSerializerSettings
|
||||
var body = JsonConvert.SerializeObject(data, new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver()
|
||||
});
|
||||
@@ -145,16 +139,14 @@ public class AiGateWayManager : DomainService
|
||||
/// 聊天完成-缓存处理
|
||||
/// </summary>
|
||||
/// <param name="httpContext"></param>
|
||||
/// <param name="modelId"></param>
|
||||
/// <param name="messages"></param>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task CompleteChatStreamForStatisticsAsync(
|
||||
HttpContext httpContext,
|
||||
string modelId,
|
||||
List<ChatMessage> messages,
|
||||
ThorChatCompletionsRequest request,
|
||||
Guid? userId = null,
|
||||
Guid? sessionId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -167,8 +159,8 @@ public class AiGateWayManager : DomainService
|
||||
|
||||
|
||||
var gateWay = LazyServiceProvider.GetRequiredService<AiGateWayManager>();
|
||||
var completeChatResponse = gateWay.CompleteChatStreamAsync(modelId, messages, cancellationToken);
|
||||
var tokenUsage = new TokenUsage();
|
||||
var completeChatResponse = gateWay.CompleteChatStreamAsync(request, cancellationToken);
|
||||
var tokenUsage = new ThorUsageResponse();
|
||||
await using var writer = new StreamWriter(response.Body, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
//缓存队列算法
|
||||
@@ -205,17 +197,16 @@ public class AiGateWayManager : DomainService
|
||||
{
|
||||
await foreach (var data in completeChatResponse)
|
||||
{
|
||||
if (data.IsFinish)
|
||||
if (data.Usage is not null && data.Usage.TotalTokens is not null)
|
||||
{
|
||||
tokenUsage = data.TokenUsage;
|
||||
tokenUsage = data.Usage;
|
||||
}
|
||||
|
||||
var model = MapToMessage(modelId, data.Content);
|
||||
var message = JsonConvert.SerializeObject(model, new JsonSerializerSettings
|
||||
var message = JsonConvert.SerializeObject(data, new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver()
|
||||
});
|
||||
backupSystemContent.Append(data.Content);
|
||||
backupSystemContent.Append(data.Choices.FirstOrDefault()?.Delta.Content);
|
||||
// 将消息加入队列而不是直接写入
|
||||
messageQueue.Enqueue($"data: {message}\n");
|
||||
}
|
||||
@@ -223,8 +214,20 @@ public class AiGateWayManager : DomainService
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, $"Ai对话异常");
|
||||
var errorContent = $"Ai对话异常,异常信息:\n{e.Message}";
|
||||
var model = MapToMessage(modelId, errorContent);
|
||||
var errorContent = $"Ai异常,异常信息:\n当前Ai模型:{request.Model}\n异常信息:{e.Message}";
|
||||
var model = new ThorChatCompletionsResponse()
|
||||
{
|
||||
Choices = new List<ThorChatChoiceResponse>()
|
||||
{
|
||||
new ThorChatChoiceResponse()
|
||||
{
|
||||
Delta = new ThorChatMessage()
|
||||
{
|
||||
Content = errorContent
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
var message = JsonConvert.SerializeObject(model, new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver()
|
||||
@@ -245,8 +248,8 @@ public class AiGateWayManager : DomainService
|
||||
await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId,
|
||||
new MessageInputDto
|
||||
{
|
||||
Content = messages.LastOrDefault().Content.FirstOrDefault()?.Text ?? string.Empty,
|
||||
ModelId = modelId,
|
||||
Content = request.Messages.LastOrDefault()?.Content ?? string.Empty,
|
||||
ModelId = request.Model,
|
||||
TokenUsage = tokenUsage,
|
||||
});
|
||||
|
||||
@@ -254,162 +257,12 @@ public class AiGateWayManager : DomainService
|
||||
new MessageInputDto
|
||||
{
|
||||
Content = backupSystemContent.ToString(),
|
||||
ModelId = modelId,
|
||||
ModelId = request.Model,
|
||||
TokenUsage = tokenUsage
|
||||
});
|
||||
|
||||
await _usageStatisticsManager.SetUsageAsync(userId.Value, modelId, tokenUsage.InputTokenCount,
|
||||
tokenUsage.OutputTokenCount);
|
||||
await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, tokenUsage.InputTokens ?? 0,
|
||||
tokenUsage.OutputTokens ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private SendMessageStreamOutputDto MapToMessage(string modelId, string content)
|
||||
{
|
||||
var output = new SendMessageStreamOutputDto
|
||||
{
|
||||
Id = "chatcmpl-BotYP3BlN5T4g9YPnW0fBSBvKzXdd",
|
||||
Object = "chat.completion.chunk",
|
||||
Created = 1750336171,
|
||||
Model = modelId,
|
||||
Choices = new()
|
||||
{
|
||||
new Choice
|
||||
{
|
||||
Index = 0,
|
||||
Delta = new Delta
|
||||
{
|
||||
Content = content,
|
||||
Role = "assistant"
|
||||
},
|
||||
FinishReason = null,
|
||||
ContentFilterResults = new()
|
||||
{
|
||||
Hate = new()
|
||||
{
|
||||
Filtered = false,
|
||||
Detected = null
|
||||
},
|
||||
SelfHarm = new()
|
||||
{
|
||||
Filtered = false,
|
||||
Detected = null
|
||||
},
|
||||
Sexual = new()
|
||||
{
|
||||
Filtered = false,
|
||||
Detected = null
|
||||
},
|
||||
Violence = new()
|
||||
{
|
||||
Filtered = false,
|
||||
Detected = null
|
||||
},
|
||||
Jailbreak = new()
|
||||
{
|
||||
Filtered = false,
|
||||
Detected = false
|
||||
},
|
||||
Profanity = new()
|
||||
{
|
||||
Filtered = false,
|
||||
Detected = false
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
SystemFingerprint = "",
|
||||
Usage = new Usage
|
||||
{
|
||||
PromptTokens = 0,
|
||||
CompletionTokens = 0,
|
||||
TotalTokens = 0,
|
||||
PromptTokensDetails = new()
|
||||
{
|
||||
AudioTokens = 0,
|
||||
CachedTokens = 0
|
||||
},
|
||||
CompletionTokensDetails = new()
|
||||
{
|
||||
AudioTokens = 0,
|
||||
ReasoningTokens = 0,
|
||||
AcceptedPredictionTokens = 0,
|
||||
RejectedPredictionTokens = 0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private ChatCompletionsOutput MapToChatCompletions(string modelId, string content)
|
||||
{
|
||||
return new ChatCompletionsOutput
|
||||
{
|
||||
Id = "resp_67ccd2bed1ec8190b14f964abc0542670bb6a6b452d3795b",
|
||||
Object = "response",
|
||||
CreatedAt = 1741476542,
|
||||
Status = "completed",
|
||||
Error = null,
|
||||
IncompleteDetails = null,
|
||||
Instructions = null,
|
||||
MaxOutputTokens = null,
|
||||
Model = modelId,
|
||||
Output = new List<Output>()
|
||||
{
|
||||
new Output
|
||||
{
|
||||
Type = "message",
|
||||
Id = "msg_67ccd2bf17f0819081ff3bb2cf6508e60bb6a6b452d3795b",
|
||||
Status = "completed",
|
||||
Role = "assistant",
|
||||
Content = new List<Content>
|
||||
{
|
||||
new Content
|
||||
{
|
||||
Type = "output_text",
|
||||
Text = content,
|
||||
Annotations = new List<object>()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ParallelToolCalls = true,
|
||||
PreviousResponseId = null,
|
||||
Reasoning = new Reasoning
|
||||
{
|
||||
Effort = null,
|
||||
Summary = null
|
||||
},
|
||||
Store = true,
|
||||
Temperature = 0,
|
||||
Text = new Text
|
||||
{
|
||||
Format = new Format
|
||||
{
|
||||
Type = "text"
|
||||
}
|
||||
},
|
||||
ToolChoice = "auto",
|
||||
Tools = new List<object>(),
|
||||
TopP = 1.0,
|
||||
Truncation = "disabled",
|
||||
Usage = new Application.Contracts.Dtos.OpenAi.Usage
|
||||
{
|
||||
InputTokens = 0,
|
||||
InputTokensDetails = new InputTokensDetails
|
||||
{
|
||||
CachedTokens = 0
|
||||
},
|
||||
OutputTokens = 0,
|
||||
OutputTokensDetails = new OutputTokensDetails
|
||||
{
|
||||
ReasoningTokens = 0
|
||||
},
|
||||
TotalTokens = 0
|
||||
},
|
||||
User = null,
|
||||
Metadata = null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ using Volo.Abp.Domain.Services;
|
||||
using Yi.Framework.AiHub.Domain.Entities;
|
||||
using Yi.Framework.SqlSugarCore.Abstractions;
|
||||
|
||||
namespace Yi.Framework.AiHub.Domain.Managers;
|
||||
|
||||
public class UsageStatisticsManager : DomainService
|
||||
{
|
||||
private readonly ISqlSugarRepository<UsageStatisticsAggregateRoot> _repository;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Volo.Abp.Domain;
|
||||
using Yi.Framework.AiHub.Domain.AiChat;
|
||||
using Yi.Framework.AiHub.Domain.AiChat.Impl;
|
||||
using Yi.Framework.AiHub.Domain.AiGateWay;
|
||||
using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorAzureDatabricks.Chats;
|
||||
using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorAzureOpenAI.Chats;
|
||||
using Yi.Framework.AiHub.Domain.Shared;
|
||||
using Yi.Framework.Mapster;
|
||||
|
||||
@@ -20,9 +21,10 @@ namespace Yi.Framework.AiHub.Domain
|
||||
var services = context.Services;
|
||||
|
||||
// Configure<AiGateWayOptions>(configuration.GetSection("AiGateWay"));
|
||||
//
|
||||
services.AddKeyedTransient<IChatService, AzureChatService>(nameof(AzureChatService));
|
||||
services.AddKeyedTransient<IChatService, AzureRestChatService>(nameof(AzureRestChatService));
|
||||
services.AddKeyedTransient<IChatCompletionService, AzureOpenAiChatCompletionCompletionsService>(
|
||||
nameof(AzureOpenAiChatCompletionCompletionsService));
|
||||
services.AddKeyedTransient<IChatCompletionService, AzureDatabricksChatCompletionsService>(
|
||||
nameof(AzureDatabricksChatCompletionsService));
|
||||
}
|
||||
|
||||
public override async Task OnApplicationInitializationAsync(ApplicationInitializationContext context)
|
||||
|
||||
Reference in New Issue
Block a user