feat: Thor搭建

This commit is contained in:
ccnetcore
2025-07-17 23:10:26 +08:00
parent 10f7499066
commit e593f2cba4
54 changed files with 2526 additions and 867 deletions

View File

@@ -1,4 +1,5 @@
using SqlSugar; using SqlSugar;
using Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
using Yi.Framework.AiHub.Domain.Shared.Dtos; using Yi.Framework.AiHub.Domain.Shared.Dtos;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos; namespace Yi.Framework.AiHub.Application.Contracts.Dtos;
@@ -10,5 +11,5 @@ public class MessageInputDto
public string ModelId { get; set; } public string ModelId { get; set; }
public string? Remark { get; set; } public string? Remark { get; set; }
public TokenUsage? TokenUsage { get; set; } public ThorUsageResponse? TokenUsage { get; set; }
} }

View File

@@ -1,50 +0,0 @@
using System.Collections;
using System.Text.Json;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAiDto;
public class ChatCompletionsInput
{
public List<OpenAiMessage> Messages { get; set; }
public bool? Stream { get; set; }
public string? Prompt { get; set; }
public string Model { get; set; }
public decimal? Temperature { get; set; }
public int? max_tokens { get; set; }
}
public class OpenAiMessage
{
public string? Role { get; set; }
public object? Content { get; set; }
public string ConvertContent()
{
if (Content is string content)
{
return content;
}
if (Content is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Array)
{
var contentItems = jsonElement.Deserialize<List<ContentItem>>();
var currentContentItem = contentItems.FirstOrDefault();
if (currentContentItem.type == "text")
{
return currentContentItem.text;
}
}
throw new UserFriendlyException("当前格式暂不支持");
}
}
public class ContentItem
{
public string? type { get; set; }
public string? text { get; set; }
}

View File

@@ -1,115 +0,0 @@
using Newtonsoft.Json;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
public class ChatCompletionsOutput
{
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("object")]
public string Object { get; set; }
[JsonProperty("created_at")]
public long CreatedAt { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("error")]
public object Error { get; set; }
[JsonProperty("incomplete_details")]
public object IncompleteDetails { get; set; }
[JsonProperty("instructions")]
public object Instructions { get; set; }
[JsonProperty("max_output_tokens")]
public object MaxOutputTokens { get; set; }
[JsonProperty("model")]
public string Model { get; set; }
[JsonProperty("output")]
public List<Output> Output { get; set; }
[JsonProperty("parallel_tool_calls")]
public bool ParallelToolCalls { get; set; }
[JsonProperty("previous_response_id")]
public object PreviousResponseId { get; set; }
[JsonProperty("reasoning")]
public Reasoning Reasoning { get; set; }
[JsonProperty("store")]
public bool Store { get; set; }
[JsonProperty("temperature")]
public double Temperature { get; set; }
[JsonProperty("text")]
public Text Text { get; set; }
[JsonProperty("tool_choice")]
public string ToolChoice { get; set; }
[JsonProperty("tools")]
public List<object> Tools { get; set; }
[JsonProperty("top_p")]
public double TopP { get; set; }
[JsonProperty("truncation")]
public string Truncation { get; set; }
[JsonProperty("usage")]
public Usage Usage { get; set; }
[JsonProperty("user")]
public object User { get; set; }
[JsonProperty("metadata")]
public Dictionary<string, object> Metadata { get; set; }
}
public class Output
{
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("role")]
public string Role { get; set; }
[JsonProperty("content")]
public List<Content> Content { get; set; }
}
public class Content
{
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("text")]
public string Text { get; set; }
[JsonProperty("annotations")]
public List<object> Annotations { get; set; }
}
public class Reasoning
{
[JsonProperty("effort")]
public object Effort { get; set; }
[JsonProperty("summary")]
public object Summary { get; set; }
}
public class Text
{
[JsonProperty("format")]
public Format Format { get; set; }
}
public class Format
{
[JsonProperty("type")]
public string Type { get; set; }
}
public class Usage
{
[JsonProperty("input_tokens")]
public int InputTokens { get; set; }
[JsonProperty("input_tokens_details")]
public InputTokensDetails InputTokensDetails { get; set; }
[JsonProperty("output_tokens")]
public int OutputTokens { get; set; }
[JsonProperty("output_tokens_details")]
public OutputTokensDetails OutputTokensDetails { get; set; }
[JsonProperty("total_tokens")]
public int TotalTokens { get; set; }
}
public class InputTokensDetails
{
[JsonProperty("cached_tokens")]
public int CachedTokens { get; set; }
}
public class OutputTokensDetails
{
[JsonProperty("reasoning_tokens")]
public int ReasoningTokens { get; set; }
}

View File

@@ -1,14 +0,0 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAiDto;
public class ModelGetOutput
{
public List<ModelDataOutput> Data { get; set; }
}
public class ModelDataOutput
{
public string ModelId { get; set; }
public string Object { get; set; }
public string Owned_by { get; set; }
public List<string> Permission { get; set; }
}

View File

@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
public class ModelsListDto
{
[JsonPropertyName("object")] public string @object { get; set; }
[JsonPropertyName("data")] public List<ModelsDataDto> Data { get; set; }
public ModelsListDto()
{
Data = new();
}
}
public class ModelsDataDto
{
[JsonPropertyName("id")] public string Id { get; set; }
[JsonPropertyName("object")] public string @object { get; set; }
[JsonPropertyName("created")] public long Created { get; set; }
[JsonPropertyName("owned_by")] public string OwnedBy { get; set; }
[JsonPropertyName("type")] public string Type { get; set; }
}

View File

@@ -0,0 +1,28 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
/// <summary>
/// OpenAI常量
/// </summary>
public static class OpenAIConstant
{
/// <summary>
/// 字符串utf-8编码
/// </summary>
/// <returns></returns>
public const string Done = "[DONE]";
/// <summary>
/// Data: 协议头
/// </summary>
public const string Data = "data:";
/// <summary>
/// think: 协议头
/// </summary>
public const string ThinkStart = "<think>";
/// <summary>
/// think: 协议尾
/// </summary>
public const string ThinkEnd = "</think>";
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
public sealed class ThorChatAudioRequest
{
[JsonPropertyName("voice")]
public string? Voice { get; set; }
[JsonPropertyName("format")]
public string? Format { get; set; }
}

View File

@@ -0,0 +1,66 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
/// <summary>
/// 聊天完成选项列
/// </summary>
public record ThorChatChoiceResponse
{
/// <summary>
/// 模型生成的聊天完成消息。【流式】模型响应生成的聊天完成增量存储在此属性。<br/>
/// 在当前模型中无论流式还是非流式Message 和 Delta存储相同的值
/// </summary>
[JsonPropertyName("delta")]
public ThorChatMessage Delta
{
get => Message;
set => Message = value;
}
/// <summary>
/// 模型生成的聊天完成消息。【非流式】返回的消息存储在此属性。<br/>
/// 在当前模型中无论流式还是非流式Message 和 Delta存储相同的值
/// </summary>
[JsonPropertyName("message")]
public ThorChatMessage Message { get; set; }
/// <summary>
/// 选项列表中选项的索引。
/// </summary>
[JsonPropertyName("index")]
public int? Index { get; set; }
/// <summary>
/// 用于处理请求的服务层。仅当在请求中指定了 service_tier 参数时,才包含此字段。
/// </summary>
[JsonPropertyName("service_tier")]
public string? ServiceTier { get; set; }
/// <summary>
/// 模型停止生成令牌的原因。
/// stop 如果模型达到自然停止点或提供的停止序列,
/// length 如果达到请求中指定的最大标记数,
/// content_filter 如果由于内容过滤器中的标志而省略了内容,
/// tool_calls 如果模型调用了工具,或者 function_call (已弃用)
/// 如果模型调用了函数,则会出现这种情况。
/// </summary>
[JsonPropertyName("finish_reason")]
public string? FinishReason { get; set; }
/// <summary>
/// 此指纹表示模型运行时使用的后端配置。
/// 可以与 seed 请求参数结合使用,以了解何时进行了可能影响确定性的后端更改。
/// </summary>
[JsonPropertyName("finish_details")]
public FinishDetailsResponse? FinishDetails { get; set; }
/// <summary>
///
/// </summary>
public class FinishDetailsResponse
{
[JsonPropertyName("type")] public string Type { get; set; }
[JsonPropertyName("stop")] public string Stop { get; set; }
}
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
public class ThorChatClaudeThinking
{
[JsonPropertyName("type")]
public string? Type { get; set; }
[JsonPropertyName("budget_tokens")]
public int? BudgetToken { get; set; }
}

View File

@@ -0,0 +1,289 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
/// <summary>
/// 对话补全请求参数对象
/// </summary>
public class ThorChatCompletionsRequest
{
public ThorChatCompletionsRequest()
{
Messages = new List<ThorChatMessage>();
}
[JsonPropertyName("store")]
public bool? Store { get; set; }
/// <summary>
/// 表示对话中支持的模态类型数组。可以为 null。
/// </summary>
[JsonPropertyName("modalities")]
public string[]? Modalities { get; set; }
/// <summary>
/// 表示对话中的音频请求参数。可以为 null。
/// </summary>
[JsonPropertyName("audio")] public ThorChatAudioRequest? Audio { get; set; }
/// <summary>
/// 包含迄今为止对话的消息列表
/// </summary>
[JsonPropertyName("messages")]
public List<ThorChatMessage> Messages { get; set; }
/// <summary>
/// 模型唯一编码值,如 gpt-4gpt-3.5-turbo,moonshot-v1-8k看底层具体平台定义
/// </summary>
[JsonPropertyName("model")]
public string Model { get; set; }
/// <summary>
/// 温度采样的替代方法称为核采样,介于 0 和 1 之间,其中模型考虑具有 top_p 概率质量的标记的结果。
/// 因此 0.1 意味着仅考虑包含前 10% 概率质量的标记。
/// 我们通常建议更改此项或 temperature ,但不要同时更改两者。
/// 默认 1
/// </summary>
[JsonPropertyName("top_p")]
public float? TopP { get; set; }
/// <summary>
/// 使用什么采样温度,介于 0 和 2 之间。
/// 较高的值(如 0.8)将使输出更加随机,而较低的值(如 0.2)将使其更加集中和确定性。
/// 我们通常建议更改此项或 top_p ,但不要同时更改两者。
/// 默认 1
/// </summary>
[JsonPropertyName("temperature")]
public float? Temperature { get; set; }
/// <summary>
/// 为每条输入消息生成多少个结果
/// <para>
/// 默认为 1不得大于 5。特别的当 temperature 非常小靠近 0 的时候,
/// 我们只能返回 1 个结果,如果这个时候 n 已经设置并且 > 1
/// 我们的服务会返回不合法的输入参数(invalid_request_error)
/// </para>
/// </summary>
[JsonPropertyName("n")]
public int? N { get; set; }
/// <summary>
/// 如果设置,将发送部分消息增量,就像在 ChatGPT 中一样。
/// 令牌可用时将作为仅数据服务器发送事件发送,流由 data: [DONE] 消息终止。
/// </summary>
[JsonPropertyName("stream")]
public bool? Stream { get; set; }
/// <summary>
/// 流响应选项。仅当您设置 stream: true 时才设置此项。
/// </summary>
[JsonPropertyName("stream_options")]
public ThorStreamOptions? StreamOptions { get; set; }
/// <summary>
/// 停止词,当全匹配这个(组)词后会停止输出,这个(组)词本身不会输出。
/// 最多不能超过 5 个字符串,每个字符串不得超过 32 字节,
/// 默认 null
/// </summary>
[JsonIgnore]
public string? Stop { get; set; }
/// <summary>
/// 停止词,当全匹配这个(组)词后会停止输出,这个(组)词本身不会输出。
/// 最多不能超过 5 个字符串,每个字符串不得超过 32 字节,
/// 默认 null
/// </summary>
[JsonIgnore]
public IList<string>? StopAsList { get; set; }
/// <summary>
/// 停止词,当全匹配这个(组)词后会停止输出,这个(组)词本身不会输出。
/// 最多不能超过 5 个字符串,每个字符串不得超过 32 字节,
/// 默认 null
/// </summary>
[JsonPropertyName("stop")]
public IList<string>? StopCalculated
{
get
{
if (Stop is not null && StopAsList is not null)
{
throw new ValidationException(
"Stop 和 StopAsList 不能同时有值,其中一个应该为 null");
}
if (Stop is not null)
{
return new List<string> { Stop };
}
return StopAsList;
}
}
/// <summary>
/// 生成的答案允许的最大令牌数。默认情况下模型可以返回的令牌数量为4096个提示令牌
/// </summary>
/// <see href="https://platform.openai.com/docs/api-reference/completions/create#completions/create-max_tokens" />
[JsonPropertyName("max_tokens")]
public int? MaxTokens { get; set; }
/// <summary>
/// 可为补全生成的令牌数量的上限,包括可见输出令牌和推理令牌。
/// </summary>
[JsonPropertyName("max_completion_tokens")]
public int? MaxCompletionTokens { get; set; }
/// <summary>
/// 存在惩罚,介于 -2.0 到 2.0 之间的数字。
/// 正值会根据新生成的词汇是否出现在文本中来进行惩罚,增加模型讨论新话题的可能性,
/// 默认为 0
/// </summary>
/// <seealso href="https://platform.openai.com/docs/api-reference/parameter-details" />
[JsonPropertyName("presence_penalty")]
public float? PresencePenalty { get; set; }
/// <summary>
/// 频率惩罚,介于-2.0到2.0之间的数字。
/// 正值会根据新生成的词汇在文本中现有的频率来进行惩罚,减少模型一字不差重复同样话语的可能性.
/// 默认为 0
/// </summary>
/// <seealso href="https://platform.openai.com/docs/api-reference/parameter-details" />
[JsonPropertyName("frequency_penalty")]
public float? FrequencyPenalty { get; set; }
/// <summary>
/// 接受一个 JSON 对象,该对象将标记(由标记生成器中的标记 ID 指定)映射到从 -100 到 100 的关联偏差值。
/// 从数学上讲,偏差会在采样之前添加到模型生成的 logits 中。
/// 每个模型的确切效果会有所不同,但 -1 和 1 之间的值应该会降低或增加选择的可能性;
/// 像 -100 或 100 这样的值应该会导致相关令牌的禁止或独占选择。
/// </summary>
/// <seealso href="https://platform.openai.com/tokenizer?view=bpe" />
[JsonPropertyName("logit_bias")]
public object? LogitBias { get; set; }
/// <summary>
/// 是否返回输出标记的对数概率。如果为 true则返回 message 的 content 中返回的每个输出标记的对数概率。
/// </summary>
[JsonPropertyName("logprobs")]
public bool? Logprobs { get; set; }
/// <summary>
/// 0 到 20 之间的整数,指定每个标记位置最有可能返回的标记数量,每个标记都有关联的对数概率。
/// 如果使用此参数, logprobs 必须设置为 true 。
/// </summary>
[JsonPropertyName("top_logprobs")]
public int? TopLogprobs { get; set; }
/// <summary>
/// 指定用于处理请求的延迟层。此参数与订阅规模层服务的客户相关:
/// 如果设置为“auto”系统将使用规模等级积分直至用完。
/// 如果设置为“default”则将使用具有较低正常运行时间 SLA 且无延迟保证的默认服务层来处理请求。
/// 默认null
/// </summary>
[JsonPropertyName("service_tier")]
public string? ServiceTier { get; set; }
/// <summary>
/// 模型可能调用的工具列表。目前,仅支持函数作为工具。使用它来提供模型可以为其生成 JSON 输入的函数列表。最多支持 128 个功能。
/// </summary>
[JsonPropertyName("tools")]
public List<ThorToolDefinition>? Tools { get; set; }
/// <summary>
/// 控制模型调用哪个(如果有)工具。
/// none 表示模型不会调用任何工具,而是生成一条消息。
/// auto 表示模型可以在生成消息或调用一个或多个工具之间进行选择。
/// required 表示模型必须调用一个或多个工具。
/// 通过 {"type": "function", "function": {"name": "my_function"}} 指定特定工具会强制模型调用该工具。
/// 当不存在任何工具时, none 是默认值。如果存在工具,则 auto 是默认值。
/// </summary>
[JsonIgnore]
public ThorToolChoice? ToolChoice { get; set; }
[JsonPropertyName("tool_choice")]
public object? ToolChoiceCalculated
{
get
{
if (ToolChoice != null &&
ToolChoice.Type != ThorToolChoiceTypeConst.Function &&
ToolChoice.Function != null)
{
throw new ValidationException(
"当 type 为 \"function\" 时,属性 Function 不可为null。");
}
if (ToolChoice?.Type == ThorToolChoiceTypeConst.Function)
{
return ToolChoice;
}
return ToolChoice?.Type;
}
set
{
if (value is JsonElement jsonElement)
{
if (jsonElement.ValueKind == JsonValueKind.String)
{
ToolChoice = new ThorToolChoice
{
Type = jsonElement.GetString()
};
}
else if (jsonElement.ValueKind == JsonValueKind.Object)
{
ToolChoice = jsonElement.Deserialize<ThorToolChoice>();
}
}
else
{
ToolChoice = (ThorToolChoice)value;
}
}
}
/// <summary>
/// 设置为 {"type": "json_object"} 可启用 JSON 模式,从而保证模型生成的信息是有效的 JSON。
/// 当你将 response_format 设置为 {"type": "json_object"} 时,
/// 你需要在 prompt 中明确地引导模型输出 JSON 格式的内容,
/// 并告知模型该 JSON 的具体格式,否则将可能导致不符合预期的结果。
/// 默认为 {"type": "text"}
/// </summary>
[JsonPropertyName("response_format")]
public ThorResponseFormat? ResponseFormat { get; set; }
[JsonPropertyName("metadata")] public Dictionary<string, string> Metadata { get; set; }
/// <summary>
/// 此功能处于测试阶段。
/// 如果指定,我们的系统将尽最大努力进行确定性采样,
/// 以便具有相同 seed 和参数的重复请求应返回相同的结果。
/// 不保证确定性,您应该参考 system_fingerprint 响应参数来监控后端的变化。
/// </summary>
[JsonPropertyName("seed")]
public int? Seed { get; set; }
/// <summary>
/// 代表您的最终用户的唯一标识符,可以帮助 OpenAI 监控和检测滥用行为。
/// </summary>
[JsonPropertyName("user")]
public string User { get; set; }
[JsonPropertyName("thinking")] public ThorChatClaudeThinking Thinking { get; set; }
/// <summary>
/// 参数验证
/// </summary>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public IEnumerable<ValidationResult> Validate()
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,63 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
/// <summary>
/// 对话补全服务返回结果
/// </summary>
public record ThorChatCompletionsResponse
{
/// <summary>
/// 对话补全的唯一标识符。<br/>
/// 聊天完成的唯一标识符。如果是流式对话,每个区块都具有相同的 ID。
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; }
/// <summary>
/// 用于对话补全的模型。
/// </summary>
[JsonPropertyName("model")]
public string? Model { get; set; }
/// <summary>
/// 对象类型<br/>
/// 非流式对话补全始终为 chat.completion<br/>
/// 流式对话补全始终为 chat.completion.chunk<br/>
/// </summary>
[JsonPropertyName("object")]
public string? ObjectTypeName { get; set; }
/// <summary>
/// 对话补全选项列表。如果 n 大于 1则可以是多个。
/// </summary>
[JsonPropertyName("choices")]
public List<ThorChatChoiceResponse>? Choices { get; set; }
/// <summary>
/// 完成请求的使用情况统计信息。
/// 仅在您 stream_options: {"include_usage": true} 设置请求时才会显示。
/// 如果存在,则它包含一个 null 值,但最后一个块除外,该块包含整个请求的令牌使用情况统计信息。
/// </summary>
[JsonPropertyName("usage")]
public ThorUsageResponse? Usage { get; set; }
/// <summary>
/// 创建对话补全时的 Unix 时间戳(以秒为单位)。
/// </summary>
[JsonPropertyName("created")]
public int Created { get; set; }
/// <summary>
/// 此指纹表示模型运行时使用的后端配置。
/// 可以与 seed 请求参数结合使用,以了解何时进行了可能影响确定性的后端更改。
/// </summary>
[JsonPropertyName("system_fingerprint")]
public string SystemFingerPrint { get; set; }
/// <summary>
/// 错误信息
/// </summary>
[JsonPropertyName("error")]
public ThorError? Error { get; set; }
}

View File

@@ -0,0 +1,193 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
/// <summary>
/// 聊天消息体建议使用CreeateXXX系列方法构建内容
/// </summary>
public class ThorChatMessage
{
/// <summary>
///
/// </summary>
public ThorChatMessage()
{
}
/// <summary>
/// 【必填】发出消息的角色,请使用<see cref="ThorChatMessageRoleConst.User"/>赋值,如ThorChatMessageRoleConst.User
/// </summary>
[JsonPropertyName("role")]
public string Role { get; set; }
/// <summary>
/// 发出的消息内容,如:你好
/// </summary>
[JsonIgnore]
public string? Content { get; set; }
/// <summary>
/// 发出的消息内容,仅当使用 gpt-4o 模型时才支持图像输入。
/// </summary>
/// <example>
/// 示例数据:
/// "content": [
/// {
/// "type": "text",
/// "text": "What'\''s in this image?"
/// },
/// {
/// "type": "image_url",
/// "image_url": {
/// "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg"
/// }
/// }
/// ]
/// </example>
[JsonIgnore]
public IList<ThorChatMessageContent>? Contents { get; set; }
/// <summary>
/// 发出的消息内容计算用于json序列号和反序列化Content 和 Contents 不能同时赋值,只能二选一
/// </summary>
[JsonPropertyName("content")]
public object ContentCalculated
{
get
{
if (Content is not null && Contents is not null)
{
throw new ValidationException("Messages 中 Content 和 Contents 字段不能同时有值");
}
if (Content is not null)
{
return Content;
}
return Contents!;
}
set
{
if (value is JsonElement str)
{
if (str.ValueKind == JsonValueKind.String)
{
Content = value?.ToString();
}
else if (str.ValueKind == JsonValueKind.Array)
{
Contents = JsonSerializer.Deserialize<IList<ThorChatMessageContent>>(value?.ToString());
}
}
else
{
Content = value?.ToString();
}
}
}
/// <summary>
/// 【可选】参与者的可选名称。提供模型信息以区分相同角色的参与者。
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>
/// 工具调用 ID,此消息正在响应的工具调用。
/// </summary>
[JsonPropertyName("tool_call_id")]
public string? ToolCallId { get; set; }
/// <summary>
/// 函数调用,已过期,不要使用,请使用 ToolCalls
/// </summary>
[JsonPropertyName("function_call")]
public ThorChatMessageFunction? FunctionCall { get; set; }
/// <summary>
/// 【可选】推理内容
/// </summary>
[JsonPropertyName("reasoning_content")]
public string? ReasoningContent { get; set; }
[JsonPropertyName("id")]
public string? Id { get; set; }
/// <summary>
/// 工具调用列表,模型生成的工具调用,例如函数调用。<br/>
/// 此属性存储在客户端进行tool use 第一次调用模型返回的使用的函数名和传入的参数
/// </summary>
[JsonPropertyName("tool_calls")]
public List<ThorToolCall>? ToolCalls { get; set; }
/// <summary>
/// 创建系统消息
/// </summary>
/// <param name="content">系统消息内容</param>
/// <param name="name">参与者的可选名称。提供模型信息以区分同一角色的参与者。</param>
/// <returns></returns>
public static ThorChatMessage CreateSystemMessage(string content, string? name = null)
{
return new()
{
Role = ThorChatMessageRoleConst.System,
Content = content,
Name = name
};
}
/// <summary>
/// 创建用户消息
/// </summary>
/// <param name="content">系统消息内容</param>
/// <param name="name">参与者的可选名称。提供模型信息以区分同一角色的参与者。</param>
/// <returns></returns>
public static ThorChatMessage CreateUserMessage(string content, string? name = null)
{
return new()
{
Role = ThorChatMessageRoleConst.User,
Content = content,
Name = name
};
}
/// <summary>
/// 创建助手消息
/// </summary>
/// <param name="content">系统消息内容</param>
/// <param name="name">参与者的可选名称。提供模型信息以区分同一角色的参与者。</param>
/// <param name="toolCalls">工具调用参数列表</param>
/// <returns></returns>
public static ThorChatMessage CreateAssistantMessage(string content, string? name = null, List<ThorToolCall> toolCalls = null)
{
return new()
{
Role = ThorChatMessageRoleConst.Assistant,
Content = content,
Name = name,
ToolCalls=toolCalls,
};
}
/// <summary>
/// 创建工具消息
/// </summary>
/// <param name="content">系统消息内容</param>
/// <param name="toolCallId">工具调用 ID,此消息正在响应的工具调用。</param>
/// <returns></returns>
public static ThorChatMessage CreateToolMessage(string content, string toolCallId = null)
{
return new()
{
Role = ThorChatMessageRoleConst.Tool,
Content = content,
ToolCallId= toolCallId
};
}
}

View File

@@ -0,0 +1,11 @@

using System.Text.Json.Serialization;
public sealed class ThorChatMessageAudioContent
{
[JsonPropertyName("data")]
public string? Data { get; set; }
[JsonPropertyName("format")]
public string? Format { get; set; }
}

View File

@@ -0,0 +1,98 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
/// <summary>
/// 发出的消息内容包含图文一般是一文一图一文多图两种情况请使用CreeateXXX系列方法构建内容
/// </summary>
public class ThorChatMessageContent
{
public ThorChatMessageContent()
{
}
/// <summary>
/// 消息内容类型,只能使用<see cref="ThorMessageContentTypeConst"/> 定义的值赋值ThorMessageContentTypeConst.Text
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; }
/// <summary>
/// 消息内容类型为 text 时候的赋值,如:图片上描述了什么
/// </summary>
[JsonPropertyName("text")]
public string? Text { get; set; }
/// <summary>
/// 消息内容类型为 image_url 时候的赋值
/// </summary>
[JsonPropertyName("image_url")]
public ThorVisionImageUrl? ImageUrl { get; set; }
/// <summary>
/// 音频消息内容,包含音频数据和格式信息。
/// </summary>
[JsonPropertyName("input_audio")]
public ThorChatMessageAudioContent? InputAudio { get; set; }
/// <summary>
/// 创建文本类消息
/// <param name="text">文本内容</param>
/// </summary>
public static ThorChatMessageContent CreateTextContent(string text)
{
return new()
{
Type = ThorMessageContentTypeConst.Text,
Text = text
};
}
/// <summary>
/// 创建图片类消息图片url形式
/// <param name="imageUrl">图片 url</param>
/// <param name="detail">指定图像的详细程度。通过控制 detail 参数(该参数具有三个选项: low 、 high 或 auto ),您
/// 可以控制模型的处理方式图像并生成其文本理解。默认情况下,模型将使用 auto 设置,
/// 该设置将查看图像输入大小并决定是否应使用 low 或 high 设置。</param>
/// </summary>
public static ThorChatMessageContent CreateImageUrlContent(string imageUrl, string? detail = "auto")
{
return new()
{
Type = ThorMessageContentTypeConst.ImageUrl,
ImageUrl = new()
{
Url = imageUrl,
Detail = detail
}
};
}
/// <summary>
/// 创建图片类消息,字节流转base64字符串形式
/// <param name="binaryImage">The image binary data as byte array</param>
/// <param name="imageType">图片类型,如 png,jpg</param>
/// <param name="detail">指定图像的详细程度。</param>
/// </summary>
public static ThorChatMessageContent CreateImageBinaryContent(
byte[] binaryImage,
string imageType,
string? detail = "auto"
)
{
return new()
{
Type = ThorMessageContentTypeConst.ImageUrl,
ImageUrl = new()
{
Url = string.Format(
"data:image/{0};base64,{1}",
imageType,
Convert.ToBase64String(binaryImage)
),
Detail = detail
}
};
}
}

View File

@@ -0,0 +1,36 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi
{
/// <summary>
/// 模型调用的函数。
/// </summary>
public class ThorChatMessageFunction
{
/// <summary>
/// 功能名,如get_current_weather
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>
/// 调用函数所用的参数,由模型以 JSON 格式生成。请注意,该模型并不总是生成有效的 JSON
/// 并且可能会产生未由函数架构定义的参数。
/// 在调用函数之前验证代码中的参数。
/// 如:"{\"location\": \"San Francisco, USA\", \"format\": \"celsius\"}"
/// </summary>
[JsonPropertyName("arguments")]
public string? Arguments { get; set; }
/// <summary>
/// 转换参数为字典
/// </summary>
/// <returns></returns>
public Dictionary<string, object> ParseArguments()
{
var result = string.IsNullOrWhiteSpace(Arguments) == false ? JsonSerializer.Deserialize<Dictionary<string, object>>(Arguments) : new Dictionary<string, object>();
return result;
}
}
}

View File

@@ -0,0 +1,45 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi
{
/// <summary>
/// 对话消息角色定义
/// </summary>
public class ThorChatMessageRoleConst
{
/// <summary>
/// 系统角色
/// <para>
/// 用于为聊天助手分配特定的行为或上下文,以影响对话的模型行为。
/// 例如,可以将系统角色设定为“您是足球专家”,
/// 那么 ChatGPT 在对话中会表现出特定的个性或专业知识。
/// </para>
/// </summary>
public static string System => "system";
/// <summary>
/// 用户角色
/// <para>
/// 代表实际的最终用户,向 ChatGPT 发送提示或消息,
/// 用于指示消息/提示来自最终用户或人类。
/// </para>
/// </summary>
public static string User => "user";
/// <summary>
/// 助手角色
/// <para>
/// 表示对最终用户提示的响应实体,用于保持对话的连贯性。
/// 它是由模型自动生成并回复的,用于设置模型的先前响应,以继续对话流程。
/// </para>
/// </summary>
public static string Assistant => "assistant";
/// <summary>
/// 工具角色
/// <para>
/// 表示对最终用户提示的响应实体,用于保持对话的连贯性。
/// 它是由模型自动生成并回复的,用于设置模型的先前响应,以继续对话流程。
/// </para>
/// </summary>
public static string Tool => "tool";
}
}

View File

@@ -0,0 +1,70 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi
{
public class ThorError
{
/// <summary>
/// 错误码
/// </summary>
[JsonPropertyName("code")]
public string? Code { get; set; }
/// <summary>
/// 参数
/// </summary>
[JsonPropertyName("param")]
public string? Param { get; set; }
/// <summary>
/// 类型
/// </summary>
[JsonPropertyName("type")]
public string? Type { get; set; }
/// <summary>
/// 错误信息
/// </summary>
[JsonIgnore]
public string? Message { get; private set; }
/// <summary>
/// 错误信息
/// </summary>
[JsonIgnore]
public List<string?> Messages { get; private set; }
/// <summary>
/// 错误信息
/// </summary>
[JsonPropertyName("message")]
public object MessageObject
{
set
{
switch (value)
{
case string s:
Message = s;
Messages = new() { s };
break;
case List<object> list when list.All(i => i is JsonElement):
Messages = list.Cast<JsonElement>().Select(e => e.GetString()).ToList();
Message = string.Join(Environment.NewLine, Messages);
break;
}
}
get
{
if (Messages?.Count > 1)
{
return Messages;
}
return Message;
}
}
}
}

View File

@@ -0,0 +1,23 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi
{
/// <summary>
/// 支持图片识别的消息体内容类型
/// </summary>
public class ThorMessageContentTypeConst
{
/// <summary>
/// 文本内容
/// </summary>
public static string Text => "text";
/// <summary>
/// 图片 Url 类型
/// </summary>
public static string ImageUrl => "image_url";
/// <summary>
/// 图片 Url 类型
/// </summary>
public static string Image => "image";
}
}

View File

@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
/// <summary>
/// 指定模型必须输出的格式的对象。用于启用JSON模式。
/// </summary>
public class ThorResponseFormat
{
/// <summary>
/// 设置为json_object启用json模式。
/// 这保证了模型生成的消息是有效的JSON。
/// 注意如果finish_reason=“length”则消息内容可能是部分的
/// 这表示生成超过了max_tokens或对话超过了最大上下文长度。
/// </summary>
[JsonPropertyName("type")]
public string? Type { get; set; }
[JsonPropertyName("json_schema")]
public ThorResponseJsonSchema JsonSchema { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
public class ThorResponseJsonSchema
{
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("strict")]
public bool? Strict { get; set; }
[JsonPropertyName("schema")]
public object Schema { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi
{
/// <summary>
/// 流响应选项。仅当您设置 stream: true 时才设置此项。
/// </summary>
public class ThorStreamOptions
{
/// <summary>
/// 如果设置,则会在 data: [DONE] 消息之前传输附加块。
/// 该块上的 usage 字段显示整个请求的令牌使用统计信息,
/// choices 字段将始终为空数组。所有其他块也将包含一个 usage 字段,但具有空值。
/// </summary>
[JsonPropertyName("include_usage")]
public bool? IncludeUsage { get; set; }
}
}

View File

@@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
/// <summary>
/// 工具调用对象定义
/// </summary>
public class ThorToolCall
{
public ThorToolCall()
{
Id = Guid.NewGuid().ToString("N");
}
/// <summary>
/// 工具调用序号值
/// </summary>
[JsonPropertyName("index")]
public int Index { get; set; }
/// <summary>
/// 工具调用的 ID
/// </summary>
[JsonPropertyName("id")]
public string? Id { get; set; }
/// <summary>
/// 工具的类型。目前仅支持 function
/// </summary>
[JsonPropertyName("type")]
public string? Type { get; set; } = "function";
/// <summary>
/// 模型调用的函数。
/// </summary>
[JsonPropertyName("function")]
public ThorChatMessageFunction? Function { get; set; }
}

View File

@@ -0,0 +1,55 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
/// <summary>
/// 工具
/// </summary>
public class ThorToolChoice
{
/// <summary>
/// 表示模型不会调用任何工具
/// </summary>
public static ThorToolChoice GetNone() => new() { Type = ThorToolChoiceTypeConst.None };
/// <summary>
/// 表示模型可以在生成消息或调用一个或多个工具之间进行选择
/// </summary>
public static ThorToolChoice GetAuto() => new() { Type = ThorToolChoiceTypeConst.Auto };
/// <summary>
/// 表示模型必须调用一个或多个工具
/// </summary>
public static ThorToolChoice GetRequired() => new() { Type = ThorToolChoiceTypeConst.Required };
/// <summary>
/// 指定特定工具会强制模型调用该工具
/// </summary>
/// <param name="functionName">函数名</param>
/// <returns></returns>
public static ThorToolChoice GetFunction(string functionName) => new()
{
Type = ThorToolChoiceTypeConst.Function,
Function = new ThorToolChoiceFunctionTool()
{
Name = functionName
}
};
/// <summary>
/// "none" 表示模型不会调用任何工具<br />
/// "auto" 表示模型可以在生成消息或调用一个或多个工具之间进行选择 <br />
/// "required" 表示模型必须调用一个或多个工具 <br />
/// "function" 指定特定工具会强制模型调用该工具<br />
/// 使用<see cref="ThorToolChoiceTypeConst"/> 赋值
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; }
/// <summary>
/// 调用的函数定义
/// </summary>
[JsonPropertyName("function")]
public ThorToolChoiceFunctionTool? Function { get; set; }
}

View File

@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi
{
public class ThorToolChoiceFunctionTool
{
[JsonPropertyName("name")]
public string Name { get; set; }
}
}

View File

@@ -0,0 +1,25 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi
{
public class ThorToolChoiceTypeConst
{
/// <summary>
/// 指定特定工具会强制模型调用该工具
/// </summary>
public static string Function => "function";
/// <summary>
/// 表示模型可以在生成消息或调用一个或多个工具之间进行选择
/// </summary>
public static string Auto => "auto";
/// <summary>
/// 表示模型不会调用任何工具
/// </summary>
public static string None => "none";
/// <summary>
/// 表示模型必须调用一个或多个工具
/// </summary>
public static string Required => "required ";
}
}

View File

@@ -0,0 +1,32 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
/// <summary>
/// 有效工具的定义。
/// </summary>
public class ThorToolDefinition
{
/// <summary>
/// 必修的。工具的类型。目前仅支持 function 。
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = ThorToolTypeConst.Function;
/// <summary>
/// 函数对象
/// </summary>
[JsonPropertyName("function")]
public ThorToolFunctionDefinition? Function { get; set; }
/// <summary>
/// 创建函数工具
/// </summary>
/// <param name="function"></param>
/// <returns></returns>
public static ThorToolDefinition CreateFunctionTool(ThorToolFunctionDefinition function) => new()
{
Type = ThorToolTypeConst.Function,
Function = function
};
}

View File

@@ -0,0 +1,34 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
/// <summary>
/// 有效函数调用的定义。
/// </summary>
public class ThorToolFunctionDefinition
{
[JsonPropertyName("type")]
public string? Type { get; set; }
/// <summary>
/// 要调用的函数的名称。必须是 a-z、A-Z、0-9 或包含下划线和破折号,最大长度为 64。
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; }
/// <summary>
/// 函数功能的描述,模型使用它来选择何时以及如何调用函数。
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>
/// 函数接受的参数,描述为 JSON 架构对象。有关示例,请参阅指南,有关格式的文档,请参阅 JSON 架构参考。
/// 省略 parameters 定义一个参数列表为空的函数。
/// See the <a href="https://platform.openai.com/docs/guides/gpt/function-calling">guide</a> for examples,
/// and the <a href="https://json-schema.org/understanding-json-schema/">JSON Schema reference</a> for
/// documentation about the format.
/// </summary>
[JsonPropertyName("parameters")]
public ThorToolFunctionPropertyDefinition Parameters { get; set; }
}

View File

@@ -0,0 +1,260 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
/// <summary>
/// 函数参数是JSON格式对象
/// https://json-schema.org/understanding-json-schema/reference/object.html
/// </summary>
/// <example>
/// 定义属性示例:
/// [JsonPropertyName("location")]
/// public ThorToolFunctionPropertyDefinition Location = ThorToolFunctionPropertyDefinition.DefineString("The city and state, e.g. San Francisco, CA");
///
/// [JsonPropertyName("unit")]
/// public ThorToolFunctionPropertyDefinition Unit = ThorToolFunctionPropertyDefinition.DefineEnum(["celsius", "fahrenheit"]);
/// </example>
public class ThorToolFunctionPropertyDefinition
{
/// <summary>
/// 定义了函数对象的类型枚举
/// </summary>
public enum FunctionObjectTypes
{
/// <summary>
/// 表示字符串类型的函数对象
/// </summary>
String,
/// <summary>
/// 表示整数类型的函数对象
/// </summary>
Integer,
/// <summary>
/// 表示数字(包括浮点数等)类型的函数对象
/// </summary>
Number,
/// <summary>
/// 表示对象类型的函数对象
/// </summary>
Object,
/// <summary>
/// 表示数组类型的函数对象
/// </summary>
Array,
/// <summary>
/// 表示布尔类型的函数对象
/// </summary>
Boolean,
/// <summary>
/// 表示空值类型的函数对象
/// </summary>
Null
}
/// <summary>
/// 必填的。函数参数对象类型。默认值为“object”。
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = "object";
/// <summary>
/// 可选。“函数参数”列表,作为从参数名称映射的字典
/// 对于描述类型的对象,可能还有可能的枚举值等等。
/// </summary>
[JsonPropertyName("properties")]
public IDictionary<string, ThorToolFunctionPropertyDefinition>? Properties { get; set; }
/// <summary>
/// 可选。列出必需的“function arguments”列表。
/// </summary>
[JsonPropertyName("required")]
public List<string>? Required { get; set; }
/// <summary>
/// 可选。是否允许附加属性。默认值为true。
/// </summary>
[JsonPropertyName("additionalProperties")]
public bool? AdditionalProperties { get; set; }
/// <summary>
/// 可选。参数描述。
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>
/// 可选。此参数的允许值列表。
/// </summary>
[JsonPropertyName("enum")]
public List<string>? Enum { get; set; }
/// <summary>
/// 可以使用minProperties和maxProperties关键字限制对象上的属性数量。每一个
/// 这些必须是非负整数。
/// </summary>
[JsonPropertyName("minProperties")]
public int? MinProperties { get; set; }
/// <summary>
/// 可以使用minProperties和maxProperties关键字限制对象上的属性数量。每一个
/// 这些必须是非负整数。
/// </summary>
[JsonPropertyName("maxProperties")]
public int? MaxProperties { get; set; }
/// <summary>
/// 如果type为“array”则指定数组中所有项目的元素类型。
/// 如果类型不是“array”则应为null。
/// 有关更多详细信息,请参阅 https://json-schema.org/understanding-json-schema/reference/array.html
/// </summary>
[JsonPropertyName("items")]
public ThorToolFunctionPropertyDefinition? Items { get; set; }
/// <summary>
/// 定义数组
/// </summary>
/// <param name="arrayItems"></param>
/// <returns></returns>
public static ThorToolFunctionPropertyDefinition DefineArray(ThorToolFunctionPropertyDefinition? arrayItems = null)
{
return new ThorToolFunctionPropertyDefinition
{
Items = arrayItems,
Type = ConvertTypeToString(FunctionObjectTypes.Array)
};
}
/// <summary>
/// 定义枚举
/// </summary>
/// <param name="enumList"></param>
/// <param name="description"></param>
/// <returns></returns>
public static ThorToolFunctionPropertyDefinition DefineEnum(List<string> enumList, string? description = null)
{
return new ThorToolFunctionPropertyDefinition
{
Description = description,
Enum = enumList,
Type = ConvertTypeToString(FunctionObjectTypes.String)
};
}
/// <summary>
/// 定义整型
/// </summary>
/// <param name="description"></param>
/// <returns></returns>
public static ThorToolFunctionPropertyDefinition DefineInteger(string? description = null)
{
return new ThorToolFunctionPropertyDefinition
{
Description = description,
Type = ConvertTypeToString(FunctionObjectTypes.Integer)
};
}
/// <summary>
/// 定义数字
/// </summary>
/// <param name="description"></param>
/// <returns></returns>
public static ThorToolFunctionPropertyDefinition DefineNumber(string? description = null)
{
return new ThorToolFunctionPropertyDefinition
{
Description = description,
Type = ConvertTypeToString(FunctionObjectTypes.Number)
};
}
/// <summary>
/// 定义字符串
/// </summary>
/// <param name="description"></param>
/// <returns></returns>
public static ThorToolFunctionPropertyDefinition DefineString(string? description = null)
{
return new ThorToolFunctionPropertyDefinition
{
Description = description,
Type = ConvertTypeToString(FunctionObjectTypes.String)
};
}
/// <summary>
/// 定义布尔值
/// </summary>
/// <param name="description"></param>
/// <returns></returns>
public static ThorToolFunctionPropertyDefinition DefineBoolean(string? description = null)
{
return new ThorToolFunctionPropertyDefinition
{
Description = description,
Type = ConvertTypeToString(FunctionObjectTypes.Boolean)
};
}
/// <summary>
/// 定义null
/// </summary>
/// <param name="description"></param>
/// <returns></returns>
public static ThorToolFunctionPropertyDefinition DefineNull(string? description = null)
{
return new ThorToolFunctionPropertyDefinition
{
Description = description,
Type = ConvertTypeToString(FunctionObjectTypes.Null)
};
}
/// <summary>
/// 定义对象
/// </summary>
/// <param name="properties"></param>
/// <param name="required"></param>
/// <param name="additionalProperties"></param>
/// <param name="description"></param>
/// <param name="enum"></param>
/// <returns></returns>
public static ThorToolFunctionPropertyDefinition DefineObject(IDictionary<string, ThorToolFunctionPropertyDefinition>? properties,
List<string>? required,
bool? additionalProperties,
string? description,
List<string>? @enum)
{
return new ThorToolFunctionPropertyDefinition
{
Properties = properties,
Required = required,
AdditionalProperties = additionalProperties,
Description = description,
Enum = @enum,
Type = ConvertTypeToString(FunctionObjectTypes.Object)
};
}
/// <summary>
/// 将 `FunctionObjectTypes` 枚举值转换为其对应的字符串表示形式。
/// </summary>
/// <param name="type">要转换的类型</param>
/// <returns>给定类型的字符串表示形式</returns>
public static string ConvertTypeToString(FunctionObjectTypes type)
{
return type switch
{
FunctionObjectTypes.String => "string",
FunctionObjectTypes.Integer => "integer",
FunctionObjectTypes.Number => "number",
FunctionObjectTypes.Object => "object",
FunctionObjectTypes.Array => "array",
FunctionObjectTypes.Boolean => "boolean",
FunctionObjectTypes.Null => "null",
_ => throw new ArgumentOutOfRangeException(nameof(type), $"Unknown type: {type}")
};
}
}

View File

@@ -0,0 +1,13 @@
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi
{
/// <summary>
/// 工具类型定义
/// </summary>
public class ThorToolTypeConst
{
/// <summary>
/// 函数
/// </summary>
public static string Function => "function";
}
}

View File

@@ -0,0 +1,102 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
/// <summary>
/// 统计信息模型
/// </summary>
public record ThorUsageResponse
{
/// <summary>
/// 提示中的令牌数。
/// </summary>
[JsonPropertyName("prompt_tokens")]
public int? PromptTokens { get; set; }
[JsonPropertyName("input_tokens")]
public int? InputTokens { get; set; }
[JsonPropertyName("output_tokens")]
public int? OutputTokens { get; set; }
[JsonPropertyName("input_tokens_details")]
public ThorUsageResponseInputTokensDetails? InputTokensDetails { get; set; }
/// <summary>
/// 生成的完成中的令牌数。
/// </summary>
[JsonPropertyName("completion_tokens")]
public long? CompletionTokens { get; set; }
/// <summary>
/// 请求中使用的令牌总数(提示 + 完成)。
/// </summary>
[JsonPropertyName("total_tokens")]
public long? TotalTokens { get; set; }
/// <summary>
/// ThorUsageResponsePromptTokensDetails
/// </summary>
[JsonPropertyName("prompt_tokens_details")]
public ThorUsageResponsePromptTokensDetails? PromptTokensDetails { get; set; }
/// <summary>
/// ThorUsageResponseCompletionTokensDetails
/// </summary>
[JsonPropertyName("completion_tokens_details")]
public ThorUsageResponseCompletionTokensDetails? CompletionTokensDetails { get; set; }
}
public class ThorUsageResponseInputTokensDetails
{
[JsonPropertyName("image_tokens")]
public int? ImageTokens { get; set; }
[JsonPropertyName("text_tokens")]
public int? TextTokens { get; set; }
}
public record ThorUsageResponsePromptTokensDetails
{
/// <summary>
/// 缓存的令牌数。
/// </summary>
[JsonPropertyName("cached_tokens")]
public int? CachedTokens { get; set; }
/// <summary>
/// audio_tokens
/// </summary>
[JsonPropertyName("audio_tokens")]
public int? AudioTokens { get; set; }
}
/// <summary>
/// completion_tokens_details
/// </summary>
public record ThorUsageResponseCompletionTokensDetails
{
/// <summary>
/// 使用 Predicted Outputs 时, Prediction 的 Final。
/// </summary>
[JsonPropertyName("accepted_prediction_tokens")]
public int? AcceptedPredictionTokens { get; set; }
/// <summary>
/// 模型生成的音频输入令牌。
/// </summary>
[JsonPropertyName("audio_tokens")]
public int? AudioTokens { get; set; }
/// <summary>
/// 模型生成的用于推理的 Token。
/// </summary>
[JsonPropertyName("reasoning_tokens")]
public int? ReasoningTokens { get; set; }
/// <summary>
/// 使用 Predicted Outputs 时, 预测,但未出现在 completion 中。但是,与 reasoning 令牌,这些令牌仍然计入总数 用于 Billing、Output 和 Context Window 的完成令牌 限制。
/// </summary>
[JsonPropertyName("rejected_prediction_tokens")]
public int? RejectedPredictionTokens { get; set; }
}

View File

@@ -0,0 +1,29 @@
using System.Text.Json.Serialization;
namespace Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
/// <summary>
/// 图片消息内容对象
/// </summary>
public class ThorVisionImageUrl
{
/// <summary>
/// 图片的url地址https://localhost/logo.jpg ,一般只支持 .png , .jpg .webp .gif
/// 也可以是base64字符串,如data:image/jpeg;base64,{base64_image}
/// 要看底层平台具体要求
/// </summary>
[JsonPropertyName("url")]
public string Url { get; set; }
/// <summary>
/// 指定图像的细节级别。在愿景指南中了解更多信息。https://platform.openai.com/docs/guides/vision/low-or-high-fidelity-image-understanding
/// <para>
/// 指定图像的详细程度。通过控制 detail 参数(该参数具有三个选项: low 、 high 或 auto ),您
/// 可以控制模型的处理方式图像并生成其文本理解。默认情况下,模型将使用 auto 设置,
/// 该设置将查看图像输入大小并决定是否应使用 low 或 high 设置。
/// </para>
/// </summary>
[JsonPropertyName("detail")]
public string? Detail { get; set; } = "auto";
}

View File

@@ -12,6 +12,7 @@ using OpenAI.Chat;
using Volo.Abp.Application.Services; using Volo.Abp.Application.Services;
using Volo.Abp.Users; using Volo.Abp.Users;
using Yi.Framework.AiHub.Application.Contracts.Dtos; using Yi.Framework.AiHub.Application.Contracts.Dtos;
using Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
using Yi.Framework.AiHub.Domain.Entities; using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.Model; using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Extensions; using Yi.Framework.AiHub.Domain.Extensions;
@@ -92,7 +93,8 @@ public class AiChatService : ApplicationService
/// </summary> /// </summary>
/// <param name="input"></param> /// <param name="input"></param>
/// <param name="cancellationToken"></param> /// <param name="cancellationToken"></param>
public async Task PostSendAsync(SendMessageInput input, CancellationToken cancellationToken) public async Task PostSendAsync([FromBody] ThorChatCompletionsRequest input, [FromQuery] Guid sessionId,
CancellationToken cancellationToken)
{ {
//除了免费模型,其他的模型都要校验 //除了免费模型,其他的模型都要校验
if (!input.Model.Contains("DeepSeek-R1")) if (!input.Model.Contains("DeepSeek-R1"))
@@ -111,22 +113,8 @@ public class AiChatService : ApplicationService
throw new UserFriendlyException("未登录用户只能使用未加速的DeepSeek-R1请登录后重试"); throw new UserFriendlyException("未登录用户只能使用未加速的DeepSeek-R1请登录后重试");
} }
} }
var history = new List<ChatMessage>();
foreach (var aiChatContextDto in input.Messages)
{
if (aiChatContextDto.Role == "assistant")
{
history.Add(ChatMessage.CreateAssistantMessage(aiChatContextDto.Content));
}
else if (aiChatContextDto.Role == "user")
{
history.Add(ChatMessage.CreateUserMessage(aiChatContextDto.Content));
}
}
//ai网关代理httpcontext //ai网关代理httpcontext
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input.Model, history, await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
CurrentUser.Id, input.SessionId, cancellationToken); CurrentUser.Id, sessionId, cancellationToken);
} }
} }

View File

@@ -1,12 +1,10 @@
using Dm.util; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using OpenAI.Chat;
using SqlSugar;
using Volo.Abp.Application.Services; using Volo.Abp.Application.Services;
using Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAiDto; using Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
using Yi.Framework.AiHub.Domain.Entities.Model; using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Extensions;
using Yi.Framework.AiHub.Domain.Managers; using Yi.Framework.AiHub.Domain.Managers;
using Yi.Framework.SqlSugarCore.Abstractions; using Yi.Framework.SqlSugarCore.Abstractions;
@@ -37,45 +35,22 @@ public class OpenApiService : ApplicationService
/// <param name="input"></param> /// <param name="input"></param>
/// <param name="cancellationToken"></param> /// <param name="cancellationToken"></param>
[HttpPost("openApi/v1/chat/completions")] [HttpPost("openApi/v1/chat/completions")]
public async Task ChatCompletionsAsync([FromBody] ChatCompletionsInput input, CancellationToken cancellationToken) public async Task ChatCompletionsAsync([FromBody] ThorChatCompletionsRequest input,
CancellationToken cancellationToken)
{ {
//前面都是校验,后面才是真正的调用 //前面都是校验,后面才是真正的调用
var httpContext = this._httpContextAccessor.HttpContext; var httpContext = this._httpContextAccessor.HttpContext;
var userId = await _tokenManager.GetUserIdAsync(GetTokenByHttpContext(httpContext)); var userId = await _tokenManager.GetUserIdAsync(GetTokenByHttpContext(httpContext));
var history = new List<ChatMessage>(); //ai网关代理httpcontext
if (!string.IsNullOrWhiteSpace(input.Prompt))
{
history.add(ChatMessage.CreateSystemMessage(input.Prompt));
}
foreach (var aiChatContextDto in input.Messages)
{
if (aiChatContextDto.Role == "assistant")
{
history.Add(ChatMessage.CreateAssistantMessage(aiChatContextDto.ConvertContent()));
}
else if (aiChatContextDto.Role == "user")
{
history.Add(ChatMessage.CreateUserMessage(aiChatContextDto.ConvertContent()));
}
else if (aiChatContextDto.Role == "system")
{
history.Add(ChatMessage.CreateSystemMessage(aiChatContextDto.ConvertContent()));
}
}
//是否使用流式传输
if (input.Stream == true) if (input.Stream == true)
{ {
//ai网关代理httpcontext await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input,
await _aiGateWayManager.CompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input.Model,
history,
userId, null, cancellationToken); userId, null, cancellationToken);
} }
else else
{ {
await _aiGateWayManager.CompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input.Model, await _aiGateWayManager.CompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input, userId,
history, userId, null, null,
cancellationToken); cancellationToken);
} }
} }
@@ -85,19 +60,20 @@ public class OpenApiService : ApplicationService
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[HttpGet("openApi/v1/models")] [HttpGet("openApi/v1/models")]
public async Task<ModelGetOutput> ModelsAsync() public async Task<ModelsListDto> ModelsAsync()
{ {
var data = await _aiModelRepository._DbQueryable var data = await _aiModelRepository._DbQueryable
.OrderByDescending(x => x.OrderNum) .OrderByDescending(x => x.OrderNum)
.Select(x => new ModelDataOutput .Select(x => new ModelsDataDto
{ {
ModelId = x.ModelId, Id = x.ModelId,
Object = "model", @object = "model",
Owned_by = "organization-owner", Created = DateTime.Now.ToUnixTimeSeconds(),
Permission = new List<string>() OwnedBy = "organization-owner",
Type = x.ModelId
}).ToListAsync(); }).ToListAsync();
return new ModelGetOutput() return new ModelsListDto()
{ {
Data = data Data = data
}; };

View File

@@ -46,4 +46,10 @@ public class AiModelDescribe
/// 模型描述 /// 模型描述
/// </summary> /// </summary>
public string? Description { get; set; } public string? Description { get; set; }
/// <summary>
/// 额外url
/// </summary>
public string? ExtraUrl { get; set; }
} }

View File

@@ -1,8 +0,0 @@
namespace Yi.Framework.AiHub.Domain.Shared.Dtos;
public class CompleteChatResponse
{
public TokenUsage? TokenUsage { get; set; }
public bool IsFinish { get; set; }
public string? Content { get; set; }
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
namespace Yi.Framework.AiHub.Domain.AiGateWay.Exceptions;
public sealed class PaymentRequiredException() : Exception()
{
}

View File

@@ -0,0 +1,12 @@
namespace Yi.Framework.AiHub.Domain.AiGateWay.Exceptions;
public class ThorRateLimitException : Exception
{
public ThorRateLimitException()
{
}
public ThorRateLimitException(string message) : base(message)
{
}
}

View File

@@ -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;
}
}

View File

@@ -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)];
}
}

View File

@@ -1,27 +1,29 @@
using OpenAI.Chat; using Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
using Yi.Framework.AiHub.Domain.Shared.Dtos; 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>
/// 聊天完成-流式 /// 聊天完成-流式
/// </summary> /// </summary>
/// <param name="aiModelDescribe"></param> /// <param name="aiModelDescribe"></param>
/// <param name="messages"></param> /// <param name="input"></param>
/// <param name="cancellationToken"></param> /// <param name="cancellationToken"></param>
/// <returns></returns> /// <returns></returns>
public IAsyncEnumerable<CompleteChatResponse> CompleteChatStreamAsync(AiModelDescribe aiModelDescribe, List<ChatMessage> messages, public IAsyncEnumerable<ThorChatCompletionsResponse> CompleteChatStreamAsync(AiModelDescribe aiModelDescribe,
ThorChatCompletionsRequest input,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary> /// <summary>
/// 聊天完成-非流式 /// 聊天完成-非流式
/// </summary> /// </summary>
/// <param name="aiModelDescribe"></param> /// <param name="aiModelDescribe"></param>
/// <param name="messages"></param> /// <param name="input"></param>
/// <param name="cancellationToken"></param> /// <param name="cancellationToken"></param>
/// <returns></returns> /// <returns></returns>
public Task<CompleteChatResponse> CompleteChatAsync(AiModelDescribe aiModelDescribe, List<ChatMessage> messages, public Task<ThorChatCompletionsResponse> CompleteChatAsync(AiModelDescribe aiModelDescribe,
ThorChatCompletionsRequest input,
CancellationToken cancellationToken); CancellationToken cancellationToken);
} }

View File

@@ -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;
}
}

View File

@@ -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;
});
}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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,
};
}

View File

@@ -1,6 +1,7 @@
using Mapster; using Mapster;
using SqlSugar; using SqlSugar;
using Volo.Abp.Domain.Entities.Auditing; 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.Entities.ValueObjects;
using Yi.Framework.AiHub.Domain.Shared.Dtos; using Yi.Framework.AiHub.Domain.Shared.Dtos;
using Yi.Framework.AiHub.Domain.Shared.Enums; 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, public MessageAggregateRoot(Guid userId, Guid? sessionId, string content, string role, string modelId,
TokenUsage? tokenUsage) ThorUsageResponse? tokenUsage)
{ {
UserId = userId; UserId = userId;
SessionId = sessionId; SessionId = sessionId;
@@ -28,7 +29,12 @@ public class MessageAggregateRoot : FullAuditedAggregateRoot<Guid>
ModelId = modelId; ModelId = modelId;
if (tokenUsage is not null) 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; this.MessageType = sessionId is null ? MessageTypeEnum.Api : MessageTypeEnum.Web;

View File

@@ -6,5 +6,5 @@ public class TokenUsageValueObject
public int InputTokenCount { get; set; } public int InputTokenCount { get; set; }
public int TotalTokenCount { get; set; } public long TotalTokenCount { get; set; }
} }

View File

@@ -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;
}
}

View File

@@ -6,16 +6,13 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Serialization; using Newtonsoft.Json.Serialization;
using OpenAI.Chat;
using Volo.Abp.Domain.Services; using Volo.Abp.Domain.Services;
using Yi.Framework.AiHub.Application.Contracts.Dtos; using Yi.Framework.AiHub.Application.Contracts.Dtos;
using Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi; using Yi.Framework.AiHub.Application.Contracts.Dtos.OpenAi;
using Yi.Framework.AiHub.Domain.AiChat; using Yi.Framework.AiHub.Domain.AiGateWay;
using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.AiHub.Domain.Entities.Model; using Yi.Framework.AiHub.Domain.Entities.Model;
using Yi.Framework.AiHub.Domain.Shared.Dtos; using Yi.Framework.AiHub.Domain.Shared.Dtos;
using Yi.Framework.SqlSugarCore.Abstractions; using Yi.Framework.SqlSugarCore.Abstractions;
using Usage = Yi.Framework.AiHub.Application.Contracts.Dtos.Usage;
namespace Yi.Framework.AiHub.Domain.Managers; namespace Yi.Framework.AiHub.Domain.Managers;
@@ -70,17 +67,16 @@ public class AiGateWayManager : DomainService
/// <summary> /// <summary>
/// 聊天完成-流式 /// 聊天完成-流式
/// </summary> /// </summary>
/// <param name="modelId"></param> /// <param name="request"></param>
/// <param name="messages"></param>
/// <param name="cancellationToken"></param> /// <param name="cancellationToken"></param>
/// <returns></returns> /// <returns></returns>
public async IAsyncEnumerable<CompleteChatResponse> CompleteChatStreamAsync(string modelId, public async IAsyncEnumerable<ThorChatCompletionsResponse> CompleteChatStreamAsync(
List<ChatMessage> messages, ThorChatCompletionsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
var modelDescribe = await GetModelAsync(modelId); var modelDescribe = await GetModelAsync(request.Model);
var chatService = LazyServiceProvider.GetRequiredKeyedService<IChatService>(modelDescribe.HandlerName); var chatService = LazyServiceProvider.GetRequiredKeyedService<IChatCompletionService>(modelDescribe.HandlerName);
await foreach (var result in chatService.CompleteChatStreamAsync(modelDescribe, messages, cancellationToken)) await foreach (var result in chatService.CompleteChatStreamAsync(modelDescribe, request, cancellationToken))
{ {
yield return result; yield return result;
} }
@@ -91,14 +87,13 @@ public class AiGateWayManager : DomainService
/// 聊天完成-非流式 /// 聊天完成-非流式
/// </summary> /// </summary>
/// <param name="httpContext"></param> /// <param name="httpContext"></param>
/// <param name="modelId"></param> /// <param name="request"></param>
/// <param name="messages"></param>
/// <param name="userId"></param> /// <param name="userId"></param>
/// <param name="sessionId"></param> /// <param name="sessionId"></param>
/// <param name="cancellationToken"></param> /// <param name="cancellationToken"></param>
/// <returns></returns> /// <returns></returns>
public async Task CompleteChatForStatisticsAsync(HttpContext httpContext, string modelId, public async Task CompleteChatForStatisticsAsync(HttpContext httpContext,
List<ChatMessage> messages, ThorChatCompletionsRequest request,
Guid? userId = null, Guid? userId = null,
Guid? sessionId = null, Guid? sessionId = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -107,33 +102,32 @@ public class AiGateWayManager : DomainService
// 设置响应头,声明是 json // 设置响应头,声明是 json
response.ContentType = "application/json; charset=UTF-8"; response.ContentType = "application/json; charset=UTF-8";
await using var writer = new StreamWriter(response.Body, Encoding.UTF8, leaveOpen: true); await using var writer = new StreamWriter(response.Body, Encoding.UTF8, leaveOpen: true);
var modelDescribe = await GetModelAsync(modelId); var modelDescribe = await GetModelAsync(request.Model);
var chatService = LazyServiceProvider.GetRequiredKeyedService<IChatService>(modelDescribe.HandlerName); var chatService = LazyServiceProvider.GetRequiredKeyedService<IChatCompletionService>(modelDescribe.HandlerName);
var data = await chatService.CompleteChatAsync(modelDescribe, messages, cancellationToken); var data = await chatService.CompleteChatAsync(modelDescribe, request, cancellationToken);
if (userId is not null) if (userId is not null)
{ {
await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId, await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId,
new MessageInputDto new MessageInputDto
{ {
Content = messages.LastOrDefault().Content.FirstOrDefault()?.Text ?? string.Empty, Content = request.Messages.LastOrDefault().Content ?? string.Empty,
ModelId = modelId, ModelId = request.Model,
TokenUsage = data.TokenUsage, TokenUsage = data.Usage,
}); });
await _aiMessageManager.CreateSystemMessageAsync(userId.Value, sessionId, await _aiMessageManager.CreateSystemMessageAsync(userId.Value, sessionId,
new MessageInputDto new MessageInputDto
{ {
Content = data.Content, Content = data.Choices.FirstOrDefault()?.Delta.Content,
ModelId = modelId, ModelId = request.Model,
TokenUsage = data.TokenUsage TokenUsage = data.Usage
}); });
await _usageStatisticsManager.SetUsageAsync(userId.Value, modelId, data.TokenUsage.InputTokenCount, await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, data.Usage.InputTokens ?? 0,
data.TokenUsage.OutputTokenCount); data.Usage.OutputTokens ?? 0);
} }
var result = MapToChatCompletions(modelId, data.Content); var body = JsonConvert.SerializeObject(data, new JsonSerializerSettings
var body = JsonConvert.SerializeObject(result, new JsonSerializerSettings
{ {
ContractResolver = new CamelCasePropertyNamesContractResolver() ContractResolver = new CamelCasePropertyNamesContractResolver()
}); });
@@ -145,16 +139,14 @@ public class AiGateWayManager : DomainService
/// 聊天完成-缓存处理 /// 聊天完成-缓存处理
/// </summary> /// </summary>
/// <param name="httpContext"></param> /// <param name="httpContext"></param>
/// <param name="modelId"></param> /// <param name="request"></param>
/// <param name="messages"></param> /// <param name="userId"></param>
/// <param name="sessionId"></param> /// <param name="sessionId"></param>
/// <param name="cancellationToken"></param> /// <param name="cancellationToken"></param>
/// <param name="userId"></param>
/// <returns></returns> /// <returns></returns>
public async Task CompleteChatStreamForStatisticsAsync( public async Task CompleteChatStreamForStatisticsAsync(
HttpContext httpContext, HttpContext httpContext,
string modelId, ThorChatCompletionsRequest request,
List<ChatMessage> messages,
Guid? userId = null, Guid? userId = null,
Guid? sessionId = null, Guid? sessionId = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -167,8 +159,8 @@ public class AiGateWayManager : DomainService
var gateWay = LazyServiceProvider.GetRequiredService<AiGateWayManager>(); var gateWay = LazyServiceProvider.GetRequiredService<AiGateWayManager>();
var completeChatResponse = gateWay.CompleteChatStreamAsync(modelId, messages, cancellationToken); var completeChatResponse = gateWay.CompleteChatStreamAsync(request, cancellationToken);
var tokenUsage = new TokenUsage(); var tokenUsage = new ThorUsageResponse();
await using var writer = new StreamWriter(response.Body, Encoding.UTF8, leaveOpen: true); 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) 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(data, new JsonSerializerSettings
var message = JsonConvert.SerializeObject(model, new JsonSerializerSettings
{ {
ContractResolver = new CamelCasePropertyNamesContractResolver() ContractResolver = new CamelCasePropertyNamesContractResolver()
}); });
backupSystemContent.Append(data.Content); backupSystemContent.Append(data.Choices.FirstOrDefault()?.Delta.Content);
// 将消息加入队列而不是直接写入 // 将消息加入队列而不是直接写入
messageQueue.Enqueue($"data: {message}\n"); messageQueue.Enqueue($"data: {message}\n");
} }
@@ -223,8 +214,20 @@ public class AiGateWayManager : DomainService
catch (Exception e) catch (Exception e)
{ {
_logger.LogError(e, $"Ai对话异常"); _logger.LogError(e, $"Ai对话异常");
var errorContent = $"Ai对话异常,异常信息:\n{e.Message}"; var errorContent = $"Ai异常异常信息\n当前Ai模型{request.Model}\n异常信息{e.Message}";
var model = MapToMessage(modelId, errorContent); var model = new ThorChatCompletionsResponse()
{
Choices = new List<ThorChatChoiceResponse>()
{
new ThorChatChoiceResponse()
{
Delta = new ThorChatMessage()
{
Content = errorContent
}
}
}
};
var message = JsonConvert.SerializeObject(model, new JsonSerializerSettings var message = JsonConvert.SerializeObject(model, new JsonSerializerSettings
{ {
ContractResolver = new CamelCasePropertyNamesContractResolver() ContractResolver = new CamelCasePropertyNamesContractResolver()
@@ -245,8 +248,8 @@ public class AiGateWayManager : DomainService
await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId, await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId,
new MessageInputDto new MessageInputDto
{ {
Content = messages.LastOrDefault().Content.FirstOrDefault()?.Text ?? string.Empty, Content = request.Messages.LastOrDefault()?.Content ?? string.Empty,
ModelId = modelId, ModelId = request.Model,
TokenUsage = tokenUsage, TokenUsage = tokenUsage,
}); });
@@ -254,162 +257,12 @@ public class AiGateWayManager : DomainService
new MessageInputDto new MessageInputDto
{ {
Content = backupSystemContent.ToString(), Content = backupSystemContent.ToString(),
ModelId = modelId, ModelId = request.Model,
TokenUsage = tokenUsage TokenUsage = tokenUsage
}); });
await _usageStatisticsManager.SetUsageAsync(userId.Value, modelId, tokenUsage.InputTokenCount, await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, tokenUsage.InputTokens ?? 0,
tokenUsage.OutputTokenCount); 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
};
}
} }

View File

@@ -3,6 +3,8 @@ using Volo.Abp.Domain.Services;
using Yi.Framework.AiHub.Domain.Entities; using Yi.Framework.AiHub.Domain.Entities;
using Yi.Framework.SqlSugarCore.Abstractions; using Yi.Framework.SqlSugarCore.Abstractions;
namespace Yi.Framework.AiHub.Domain.Managers;
public class UsageStatisticsManager : DomainService public class UsageStatisticsManager : DomainService
{ {
private readonly ISqlSugarRepository<UsageStatisticsAggregateRoot> _repository; private readonly ISqlSugarRepository<UsageStatisticsAggregateRoot> _repository;

View File

@@ -1,7 +1,8 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.Domain; using Volo.Abp.Domain;
using Yi.Framework.AiHub.Domain.AiChat; using Yi.Framework.AiHub.Domain.AiGateWay;
using Yi.Framework.AiHub.Domain.AiChat.Impl; 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.AiHub.Domain.Shared;
using Yi.Framework.Mapster; using Yi.Framework.Mapster;
@@ -20,9 +21,10 @@ namespace Yi.Framework.AiHub.Domain
var services = context.Services; var services = context.Services;
// Configure<AiGateWayOptions>(configuration.GetSection("AiGateWay")); // Configure<AiGateWayOptions>(configuration.GetSection("AiGateWay"));
// services.AddKeyedTransient<IChatCompletionService, AzureOpenAiChatCompletionCompletionsService>(
services.AddKeyedTransient<IChatService, AzureChatService>(nameof(AzureChatService)); nameof(AzureOpenAiChatCompletionCompletionsService));
services.AddKeyedTransient<IChatService, AzureRestChatService>(nameof(AzureRestChatService)); services.AddKeyedTransient<IChatCompletionService, AzureDatabricksChatCompletionsService>(
nameof(AzureDatabricksChatCompletionsService));
} }
public override async Task OnApplicationInitializationAsync(ApplicationInitializationContext context) public override async Task OnApplicationInitializationAsync(ApplicationInitializationContext context)