From 345ed80ec85d025739e934f27e75f2da2a60e9b0 Mon Sep 17 00:00:00 2001 From: chenchun Date: Sat, 11 Oct 2025 15:25:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9Eclaude=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E8=BD=AC=E6=8D=A2=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/OpenApiService.cs | 35 + .../Dtos/Anthropic/AnthropicCacheControl.cs | 9 + .../Anthropic/AnthropicChatCompletionDto.cs | 147 ++++ .../Dtos/Anthropic/AnthropicInput.cs | 153 +++++ .../Dtos/Anthropic/AnthropicMessageContent.cs | 76 ++ .../Dtos/Anthropic/AnthropicMessageInput.cs | 54 ++ .../Dtos/Anthropic/AnthropicToOpenAI.cs | 648 ++++++++++++++++++ .../Dtos/Anthropic/ThorJsonSerializer.cs | 14 + .../Dtos/OpenAi/ThorChatCompletionsRequest.cs | 15 +- .../Dtos/OpenAi/ThorChatWebSearchOptions.cs | 35 + .../ThorToolFunctionPropertyDefinition.cs | 63 +- .../Enums/ModelTypeEnum.cs | 3 +- .../IAnthropicChatCompletionService.cs | 29 + .../AiGateWay/ISpecialCompatible.cs | 4 +- .../Chats/AnthropicChatCompletionsService.cs | 168 +++++ ...omOpenAIAnthropicChatCompletionsService.cs | 313 +++++++++ .../Chats/OpenAIChatCompletionsService.cs | 167 +++++ .../AiGateWay/SpecialCompatible.cs | 9 + .../AiGateWay/SpecialCompatibleOptions.cs | 4 +- .../Managers/AiGateWayManager.cs | 211 +++++- .../YiFrameworkAiHubDomainModule.cs | 34 +- 21 files changed, 2161 insertions(+), 30 deletions(-) create mode 100644 Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicCacheControl.cs create mode 100644 Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicChatCompletionDto.cs create mode 100644 Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicInput.cs create mode 100644 Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicMessageContent.cs create mode 100644 Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicMessageInput.cs create mode 100644 Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicToOpenAI.cs create mode 100644 Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/ThorJsonSerializer.cs create mode 100644 Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/OpenAi/ThorChatWebSearchOptions.cs create mode 100644 Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/IAnthropicChatCompletionService.cs create mode 100644 Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorClaude/Chats/AnthropicChatCompletionsService.cs create mode 100644 Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorCustomOpenAI/Chats/CustomOpenAIAnthropicChatCompletionsService.cs create mode 100644 Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorCustomOpenAI/Chats/OpenAIChatCompletionsService.cs diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/OpenApiService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/OpenApiService.cs index 19c99e29..7c004ac6 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/OpenApiService.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Application/Services/OpenApiService.cs @@ -5,6 +5,7 @@ using Volo.Abp.Application.Services; using Yi.Framework.AiHub.Domain.Entities.Model; using Yi.Framework.AiHub.Domain.Extensions; using Yi.Framework.AiHub.Domain.Managers; +using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic; using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi; using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Embeddings; using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Images; @@ -117,6 +118,37 @@ public class OpenApiService : ApplicationService }; } + + /// + /// Anthropic对话 + /// + /// + /// + [HttpPost("openApi/v1/messages")] + public async Task MessagesAsync([FromBody] AnthropicInput input, + CancellationToken cancellationToken) + { + //前面都是校验,后面才是真正的调用 + var httpContext = this._httpContextAccessor.HttpContext; + var userId = await _tokenManager.GetUserIdAsync(GetTokenByHttpContext(httpContext)); + await _aiBlacklistManager.VerifiyAiBlacklist(userId); + //ai网关代理httpcontext + if (input.Stream) + { + await _aiGateWayManager.AnthropicCompleteChatStreamForStatisticsAsync(_httpContextAccessor.HttpContext, input, + userId, null, cancellationToken); + } + else + { + await _aiGateWayManager.AnthropicCompleteChatForStatisticsAsync(_httpContextAccessor.HttpContext, input, userId, + null, + cancellationToken); + } + } + + + #region 私有 + private string? GetTokenByHttpContext(HttpContext httpContext) { // 获取Authorization头 @@ -138,4 +170,7 @@ public class OpenApiService : ApplicationService throw new UserFriendlyException("当前海外站点不支持大流量接口,请使用转发站点:https://ai.ccnetcore.com"); } } + + #endregion + } \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicCacheControl.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicCacheControl.cs new file mode 100644 index 00000000..82dd0f23 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicCacheControl.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic; + +public sealed class AnthropicCacheControl +{ + [JsonPropertyName("type")] + public string Type { get; set; } +} \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicChatCompletionDto.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicChatCompletionDto.cs new file mode 100644 index 00000000..5e9fa1b3 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicChatCompletionDto.cs @@ -0,0 +1,147 @@ +using System.Text.Json.Serialization; +using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi; + +namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic; + +public class AnthropicStreamDto +{ + [JsonPropertyName("type")] public string? Type { get; set; } + + [JsonPropertyName("index")] public int? Index { get; set; } + + [JsonPropertyName("content_block")] public AnthropicChatCompletionDtoContentBlock? ContentBlock { get; set; } + + [JsonPropertyName("delta")] public AnthropicChatCompletionDtoDelta? Delta { get; set; } + + [JsonPropertyName("message")] public AnthropicChatCompletionDto? Message { get; set; } + + [JsonPropertyName("usage")] public AnthropicCompletionDtoUsage? Usage { get; set; } + + [JsonPropertyName("error")] public AnthropicStreamErrorDto? Error { get; set; } + + public ThorUsageResponse TokenUsage => new ThorUsageResponse + { + PromptTokens = Usage?.InputTokens, + InputTokens = Usage?.InputTokens, + OutputTokens = Usage?.OutputTokens, + InputTokensDetails = null, + CompletionTokens = Usage?.OutputTokens, + TotalTokens = Usage?.InputTokens + Usage?.OutputTokens, + PromptTokensDetails = null, + CompletionTokensDetails = null + }; +} + +public class AnthropicStreamErrorDto +{ + [JsonPropertyName("type")] public string? Type { get; set; } + + [JsonPropertyName("message")] public string? Message { get; set; } +} + +public class AnthropicChatCompletionDtoDelta +{ + [JsonPropertyName("type")] public string? Type { get; set; } + + [JsonPropertyName("text")] public string? Text { get; set; } + + [JsonPropertyName("thinking")] public string? Thinking { get; set; } + + [JsonPropertyName("partial_json")] public string? PartialJson { get; set; } + + [JsonPropertyName("stop_reason")] public string? StopReason { get; set; } +} + +public class AnthropicChatCompletionDtoContentBlock +{ + [JsonPropertyName("type")] public string? Type { get; set; } + + [JsonPropertyName("thinking")] public string? Thinking { get; set; } + + [JsonPropertyName("signature")] public string? Signature { get; set; } + + [JsonPropertyName("id")] public string? Id { get; set; } + + [JsonPropertyName("name")] public string? Name { get; set; } + + [JsonPropertyName("input")] public object? Input { get; set; } + + [JsonPropertyName("server_name")] public string? ServerName { get; set; } + + [JsonPropertyName("is_error")] public bool? IsError { get; set; } + + [JsonPropertyName("tool_use_id")] public string? ToolUseId { get; set; } + + [JsonPropertyName("content")] public object? Content { get; set; } +} + +public class AnthropicChatCompletionDto +{ + public string id { get; set; } + + public string type { get; set; } + + public string role { get; set; } + + public AnthropicChatCompletionDtoContent[] content { get; set; } + + public string model { get; set; } + + public string stop_reason { get; set; } + + public object stop_sequence { get; set; } + + public AnthropicCompletionDtoUsage Usage { get; set; } + + public ThorUsageResponse TokenUsage => new ThorUsageResponse + { + PromptTokens = Usage?.InputTokens, + InputTokens = Usage?.InputTokens, + OutputTokens = Usage?.OutputTokens, + InputTokensDetails = null, + CompletionTokens = Usage?.OutputTokens, + TotalTokens = Usage?.InputTokens + Usage?.OutputTokens, + PromptTokensDetails = null, + CompletionTokensDetails = null + }; +} + +public class AnthropicChatCompletionDtoContent +{ + public string type { get; set; } + + public string? text { get; set; } + + public string? id { get; set; } + + public string? name { get; set; } + + public object? input { get; set; } + + [JsonPropertyName("thinking")] public string? Thinking { get; set; } + + [JsonPropertyName("partial_json")] public string? PartialJson { get; set; } + + public string? signature { get; set; } +} + +public class AnthropicCompletionDtoUsage +{ + [JsonPropertyName("input_tokens")] public int? InputTokens { get; set; } + + [JsonPropertyName("cache_creation_input_tokens")] + public int? CacheCreationInputTokens { get; set; } + + [JsonPropertyName("cache_read_input_tokens")] + public int? CacheReadInputTokens { get; set; } + + [JsonPropertyName("output_tokens")] public int? OutputTokens { get; set; } + + [JsonPropertyName("server_tool_use")] public AnthropicServerToolUse? ServerToolUse { get; set; } +} + +public class AnthropicServerToolUse +{ + [JsonPropertyName("web_search_requests")] + public int? WebSearchRequests { get; set; } +} \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicInput.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicInput.cs new file mode 100644 index 00000000..49d3c2d7 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicInput.cs @@ -0,0 +1,153 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic; + +public sealed class AnthropicInput +{ + [JsonPropertyName("stream")] public bool Stream { get; set; } + + [JsonPropertyName("model")] public string Model { get; set; } + + [JsonPropertyName("max_tokens")] public int? MaxTokens { get; set; } + + [JsonPropertyName("messages")] public IList Messages { get; set; } + + [JsonPropertyName("tools")] public IList? Tools { get; set; } + + [JsonPropertyName("tool_choice")] + public object? ToolChoiceCalculated + { + get + { + if (string.IsNullOrEmpty(ToolChoiceString)) + { + return ToolChoiceString; + } + + if (ToolChoice?.Type == "function") + { + return ToolChoice; + } + + return ToolChoice?.Type; + } + set + { + if (value is JsonElement jsonElement) + { + if (jsonElement.ValueKind == JsonValueKind.String) + { + ToolChoiceString = jsonElement.GetString(); + } + else if (jsonElement.ValueKind == JsonValueKind.Object) + { + ToolChoice = jsonElement.Deserialize(ThorJsonSerializer.DefaultOptions); + } + } + else + { + ToolChoice = (AnthropicTooChoiceInput)value; + } + } + } + + [JsonIgnore] public string? ToolChoiceString { get; set; } + + [JsonIgnore] public AnthropicTooChoiceInput? ToolChoice { get; set; } + + [JsonIgnore] public IList? Systems { get; set; } + + [JsonIgnore] public string? System { get; set; } + + [JsonPropertyName("system")] + public object? SystemCalculated + { + get + { + if (System is not null && Systems is not null) + { + throw new ValidationException("System 和 Systems 字段不能同时有值"); + } + + if (System is not null) + { + return System; + } + + return Systems!; + } + set + { + if (value is JsonElement str) + { + if (str.ValueKind == JsonValueKind.String) + { + System = value?.ToString(); + } + else if (str.ValueKind == JsonValueKind.Array) + { + Systems = JsonSerializer.Deserialize>(value?.ToString(), + ThorJsonSerializer.DefaultOptions); + } + } + else + { + System = value?.ToString(); + } + } + } + + [JsonPropertyName("thinking")] public AnthropicThinkingInput? Thinking { get; set; } + + [JsonPropertyName("temperature")] public double? Temperature { get; set; } + + [JsonPropertyName("metadata")] public Dictionary? Metadata { get; set; } +} + +public class AnthropicThinkingInput +{ + [JsonPropertyName("type")] public string Type { get; set; } + + [JsonPropertyName("budget_tokens")] public int BudgetTokens { get; set; } +} + +public class AnthropicTooChoiceInput +{ + [JsonPropertyName("type")] public string? Type { get; set; } + + [JsonPropertyName("name")] public string? Name { get; set; } +} + +public class AnthropicMessageTool +{ + [JsonPropertyName("name")] public string name { get; set; } + + [JsonPropertyName("description")] public string? Description { get; set; } + + [JsonPropertyName("input_schema")] public Input_schema InputSchema { get; set; } +} + +public class Input_schema +{ + [JsonPropertyName("type")] public string Type { get; set; } + + [JsonPropertyName("properties")] public Dictionary? Properties { get; set; } + + [JsonPropertyName("required")] public string[]? Required { get; set; } +} + +public class InputSchemaValue +{ + public string type { get; set; } + + public string description { get; set; } + + public InputSchemaValueItems? items { get; set; } +} + +public class InputSchemaValueItems +{ + public string? type { get; set; } +} \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicMessageContent.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicMessageContent.cs new file mode 100644 index 00000000..ae6ecc04 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicMessageContent.cs @@ -0,0 +1,76 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic; + +public class AnthropicMessageContent +{ + [JsonPropertyName("cache_control")] public AnthropicCacheControl? CacheControl { get; set; } + + [JsonPropertyName("type")] public string Type { get; set; } + + [JsonPropertyName("text")] public string? Text { get; set; } + + [JsonPropertyName("tool_use_id")] public string? ToolUseId { get; set; } + + [JsonPropertyName("name")] public string? Name { get; set; } + + [JsonPropertyName("id")] public string? Id { get; set; } + + [JsonPropertyName("thinking")] public string? Thinking { get; set; } + + [JsonPropertyName("input")] public object? Input { get; set; } + + [JsonPropertyName("content")] + public object? Content + { + 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>(value?.ToString()); + } + } + else + { + _content = value?.ToString(); + } + } + } + + private string? _content; + + private List? _contents; + + public class AnthropicMessageContentSource + { + [JsonPropertyName("type")] public string Type { get; set; } + + [JsonPropertyName("media_type")] public string? MediaType { get; set; } + + [JsonPropertyName("data")] public string? Data { get; set; } + } + + [JsonPropertyName("source")] public AnthropicMessageContentSource? Source { get; set; } +} \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicMessageInput.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicMessageInput.cs new file mode 100644 index 00000000..8242f251 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicMessageInput.cs @@ -0,0 +1,54 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic; + +public class AnthropicMessageInput +{ + [JsonPropertyName("role")] + public string Role { get; set; } + + [JsonIgnore] + public string? Content; + + [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>(value?.ToString(),ThorJsonSerializer.DefaultOptions); + } + } + else + { + Content = value?.ToString(); + } + } + } + + [JsonIgnore] + public IList? Contents; +} \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicToOpenAI.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicToOpenAI.cs new file mode 100644 index 00000000..9f925f9e --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/AnthropicToOpenAI.cs @@ -0,0 +1,648 @@ +using System.Text.Json; +using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi; + +namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic; + +public static class AnthropicToOpenAi +{ + /// + /// 将AnthropicInput转换为ThorChatCompletionsRequest + /// + public static ThorChatCompletionsRequest ConvertAnthropicToOpenAi(AnthropicInput anthropicInput) + { + var openAiRequest = new ThorChatCompletionsRequest + { + Model = anthropicInput.Model, + MaxTokens = anthropicInput.MaxTokens, + Stream = anthropicInput.Stream, + Messages = new List(anthropicInput.Messages.Count) + }; + + // high medium minimal low + if (openAiRequest.Model.EndsWith("-high") || + openAiRequest.Model.EndsWith("-medium") || + openAiRequest.Model.EndsWith("-minimal") || + openAiRequest.Model.EndsWith("-low")) + { + openAiRequest.ReasoningEffort = openAiRequest.Model switch + { + var model when model.EndsWith("-high") => "high", + var model when model.EndsWith("-medium") => "medium", + var model when model.EndsWith("-minimal") => "minimal", + var model when model.EndsWith("-low") => "low", + _ => "medium" + }; + + openAiRequest.Model = openAiRequest.Model.Replace("-high", "") + .Replace("-medium", "") + .Replace("-minimal", "") + .Replace("-low", ""); + } + + if (anthropicInput.Thinking != null && + anthropicInput.Thinking.Type.Equals("enabled", StringComparison.OrdinalIgnoreCase)) + { + openAiRequest.Thinking = new ThorChatClaudeThinking() + { + BudgetToken = anthropicInput.Thinking.BudgetTokens, + Type = "enabled", + }; + openAiRequest.EnableThinking = true; + } + + if (openAiRequest.Model.EndsWith("-thinking")) + { + openAiRequest.EnableThinking = true; + openAiRequest.Model = openAiRequest.Model.Replace("-thinking", ""); + } + + if (openAiRequest.Stream == true) + { + openAiRequest.StreamOptions = new ThorStreamOptions() + { + IncludeUsage = true, + }; + } + + if (!string.IsNullOrEmpty(anthropicInput.System)) + { + openAiRequest.Messages.Add(ThorChatMessage.CreateSystemMessage(anthropicInput.System)); + } + + if (anthropicInput.Systems?.Count > 0) + { + foreach (var systemContent in anthropicInput.Systems) + { + openAiRequest.Messages.Add(ThorChatMessage.CreateSystemMessage(systemContent.Text ?? string.Empty)); + } + } + + // 处理messages + if (anthropicInput.Messages != null) + { + foreach (var message in anthropicInput.Messages) + { + var thorMessages = ConvertAnthropicMessageToThor(message); + // 需要过滤 空消息 + if (thorMessages.Count == 0) + { + continue; + } + + openAiRequest.Messages.AddRange(thorMessages); + } + + openAiRequest.Messages = openAiRequest.Messages + .Where(m => !string.IsNullOrEmpty(m.Content) || m.Contents?.Count > 0 || m.ToolCalls?.Count > 0 || + !string.IsNullOrEmpty(m.ToolCallId)) + .ToList(); + } + + // 处理tools + if (anthropicInput.Tools is { Count: > 0 }) + { + openAiRequest.Tools = anthropicInput.Tools.Where(x => x.name != "web_search") + .Select(ConvertAnthropicToolToThor).ToList(); + } + + // 判断是否存在web_search + if (anthropicInput.Tools?.Any(x => x.name == "web_search") == true) + { + openAiRequest.WebSearchOptions = new ThorChatWebSearchOptions() + { + }; + } + + // 处理tool_choice + if (anthropicInput.ToolChoice != null) + { + openAiRequest.ToolChoice = ConvertAnthropicToolChoiceToThor(anthropicInput.ToolChoice); + } + + return openAiRequest; + } + + /// + /// 根据最后的内容块类型和OpenAI的完成原因确定Claude的停止原因 + /// + public static string GetStopReasonByLastContentType(string? openAiFinishReason, string lastContentBlockType) + { + // 如果最后一个内容块是工具调用,优先返回tool_use + if (lastContentBlockType == "tool_use") + { + return "tool_use"; + } + + // 否则使用标准的转换逻辑 + return GetClaudeStopReason(openAiFinishReason); + } + + /// + /// 创建message_start事件 + /// + public static AnthropicStreamDto CreateMessageStartEvent(string messageId, string model) + { + return new AnthropicStreamDto + { + Type = "message_start", + Message = new AnthropicChatCompletionDto + { + id = messageId, + type = "message", + role = "assistant", + model = model, + content = new AnthropicChatCompletionDtoContent[0], + Usage = new AnthropicCompletionDtoUsage + { + InputTokens = 0, + OutputTokens = 0, + CacheCreationInputTokens = 0, + CacheReadInputTokens = 0 + } + } + }; + } + + /// + /// 创建content_block_start事件 + /// + public static AnthropicStreamDto CreateContentBlockStartEvent() + { + return new AnthropicStreamDto + { + Type = "content_block_start", + Index = 0, + ContentBlock = new AnthropicChatCompletionDtoContentBlock + { + Type = "text", + Id = null, + Name = null + } + }; + } + + /// + /// 创建thinking block start事件 + /// + public static AnthropicStreamDto CreateThinkingBlockStartEvent() + { + return new AnthropicStreamDto + { + Type = "content_block_start", + Index = 0, + ContentBlock = new AnthropicChatCompletionDtoContentBlock + { + Type = "thinking", + Id = null, + Name = null + } + }; + } + + /// + /// 创建content_block_delta事件 + /// + public static AnthropicStreamDto CreateContentBlockDeltaEvent(string text) + { + return new AnthropicStreamDto + { + Type = "content_block_delta", + Index = 0, + Delta = new AnthropicChatCompletionDtoDelta + { + Type = "text_delta", + Text = text + } + }; + } + + /// + /// 创建thinking delta事件 + /// + public static AnthropicStreamDto CreateThinkingBlockDeltaEvent(string thinking) + { + return new AnthropicStreamDto + { + Type = "content_block_delta", + Index = 0, + Delta = new AnthropicChatCompletionDtoDelta + { + Type = "thinking", + Thinking = thinking + } + }; + } + + /// + /// 创建content_block_stop事件 + /// + public static AnthropicStreamDto CreateContentBlockStopEvent() + { + return new AnthropicStreamDto + { + Type = "content_block_stop", + Index = 0 + }; + } + + /// + /// 创建message_delta事件 + /// + public static AnthropicStreamDto CreateMessageDeltaEvent(string finishReason, AnthropicCompletionDtoUsage usage) + { + return new AnthropicStreamDto + { + Type = "message_delta", + Usage = usage, + Delta = new AnthropicChatCompletionDtoDelta + { + StopReason = finishReason + } + }; + } + + /// + /// 创建message_stop事件 + /// + public static AnthropicStreamDto CreateMessageStopEvent() + { + return new AnthropicStreamDto + { + Type = "message_stop" + }; + } + + /// + /// 创建tool block start事件 + /// + public static AnthropicStreamDto CreateToolBlockStartEvent(string? toolId, string? toolName) + { + return new AnthropicStreamDto + { + Type = "content_block_start", + Index = 0, + ContentBlock = new AnthropicChatCompletionDtoContentBlock + { + Type = "tool_use", + Id = toolId, + Name = toolName + } + }; + } + + /// + /// 创建tool delta事件 + /// + public static AnthropicStreamDto CreateToolBlockDeltaEvent(string partialJson) + { + return new AnthropicStreamDto + { + Type = "content_block_delta", + Index = 0, + Delta = new AnthropicChatCompletionDtoDelta + { + Type = "input_json_delta", + PartialJson = partialJson + } + }; + } + + /// + /// 转换Anthropic消息为Thor消息列表 + /// + public static List ConvertAnthropicMessageToThor(AnthropicMessageInput anthropicMessage) + { + var results = new List(); + + // 处理简单的字符串内容 + if (anthropicMessage.Content != null) + { + var thorMessage = new ThorChatMessage + { + Role = anthropicMessage.Role, + Content = anthropicMessage.Content + }; + results.Add(thorMessage); + return results; + } + + // 处理多模态内容 + if (anthropicMessage.Contents is { Count: > 0 }) + { + var currentContents = new List(); + var currentToolCalls = new List(); + + foreach (var content in anthropicMessage.Contents) + { + switch (content.Type) + { + case "text": + currentContents.Add(ThorChatMessageContent.CreateTextContent(content.Text ?? string.Empty)); + break; + case "thinking" when !string.IsNullOrEmpty(content.Thinking): + results.Add(new ThorChatMessage() + { + ReasoningContent = content.Thinking + }); + break; + case "image": + { + if (content.Source != null) + { + var imageUrl = content.Source.Type == "base64" + ? $"data:{content.Source.MediaType};base64,{content.Source.Data}" + : content.Source.Data; + currentContents.Add(ThorChatMessageContent.CreateImageUrlContent(imageUrl ?? string.Empty)); + } + + break; + } + case "tool_use": + { + // 如果有普通内容,先创建内容消息 + if (currentContents.Count > 0) + { + if (currentContents.Count == 1 && currentContents.Any(x => x.Type == "text")) + { + var contentMessage = new ThorChatMessage + { + Role = anthropicMessage.Role, + ContentCalculated = currentContents.FirstOrDefault()?.Text ?? string.Empty + }; + results.Add(contentMessage); + } + else + { + var contentMessage = new ThorChatMessage + { + Role = anthropicMessage.Role, + Contents = currentContents + }; + results.Add(contentMessage); + } + + currentContents = new List(); + } + + // 收集工具调用 + var toolCall = new ThorToolCall + { + Id = content.Id, + Type = "function", + Function = new ThorChatMessageFunction + { + Name = content.Name, + Arguments = JsonSerializer.Serialize(content.Input) + } + }; + currentToolCalls.Add(toolCall); + break; + } + case "tool_result": + { + // 如果有普通内容,先创建内容消息 + if (currentContents.Count > 0) + { + var contentMessage = new ThorChatMessage + { + Role = anthropicMessage.Role, + Contents = currentContents + }; + results.Add(contentMessage); + currentContents = []; + } + + // 如果有工具调用,先创建工具调用消息 + if (currentToolCalls.Count > 0) + { + var toolCallMessage = new ThorChatMessage + { + Role = anthropicMessage.Role, + ToolCalls = currentToolCalls + }; + results.Add(toolCallMessage); + currentToolCalls = new List(); + } + + // 创建工具结果消息 + var toolMessage = new ThorChatMessage + { + Role = "tool", + ToolCallId = content.ToolUseId, + Content = content.Content?.ToString() ?? string.Empty + }; + results.Add(toolMessage); + break; + } + } + } + + // 处理剩余的内容 + if (currentContents.Count > 0) + { + var contentMessage = new ThorChatMessage + { + Role = anthropicMessage.Role, + Contents = currentContents + }; + results.Add(contentMessage); + } + + // 处理剩余的工具调用 + if (currentToolCalls.Count > 0) + { + var toolCallMessage = new ThorChatMessage + { + Role = anthropicMessage.Role, + ToolCalls = currentToolCalls + }; + results.Add(toolCallMessage); + } + } + + // 如果没有任何内容,返回一个空的消息 + if (results.Count == 0) + { + results.Add(new ThorChatMessage + { + Role = anthropicMessage.Role, + Content = string.Empty + }); + } + + // 如果只有一个text则使用content字段 + if (results is [{ Contents.Count: 1 }] && + results.FirstOrDefault()?.Contents?.FirstOrDefault()?.Type == "text" && + !string.IsNullOrEmpty(results.FirstOrDefault()?.Contents?.FirstOrDefault()?.Text)) + { + return + [ + new ThorChatMessage + { + Role = results[0].Role, + Content = results.FirstOrDefault()?.Contents?.FirstOrDefault()?.Text ?? string.Empty + } + ]; + } + + return results; + } + + /// + /// 转换Anthropic工具为Thor工具 + /// + public static ThorToolDefinition ConvertAnthropicToolToThor(AnthropicMessageTool anthropicTool) + { + IDictionary values = + new Dictionary(); + + if (anthropicTool.InputSchema?.Properties != null) + { + foreach (var property in anthropicTool.InputSchema.Properties) + { + if (property.Value?.description != null) + { + var definitionType = new ThorToolFunctionPropertyDefinition() + { + Description = property.Value.description, + Type = property.Value.type + }; + if (property.Value?.items?.type != null) + { + definitionType.Items = new ThorToolFunctionPropertyDefinition() + { + Type = property.Value.items.type + }; + } + + values.Add(property.Key, definitionType); + } + } + } + + + return new ThorToolDefinition + { + Type = "function", + Function = new ThorToolFunctionDefinition + { + Name = anthropicTool.name, + Description = anthropicTool.Description, + Parameters = new ThorToolFunctionPropertyDefinition + { + Type = anthropicTool.InputSchema?.Type ?? "object", + Properties = values, + Required = anthropicTool.InputSchema?.Required + } + } + }; + } + + /// + /// 将OpenAI的完成原因转换为Claude的停止原因 + /// + public static string GetClaudeStopReason(string? openAIFinishReason) + { + return openAIFinishReason switch + { + "stop" => "end_turn", + "length" => "max_tokens", + "tool_calls" => "tool_use", + "content_filter" => "stop_sequence", + _ => "end_turn" + }; + } + + /// + /// 将OpenAI响应转换为Claude响应格式 + /// + public static AnthropicChatCompletionDto ConvertOpenAIToClaude(ThorChatCompletionsResponse openAIResponse, + AnthropicInput originalRequest) + { + var claudeResponse = new AnthropicChatCompletionDto + { + id = openAIResponse.Id, + type = "message", + role = "assistant", + model = openAIResponse.Model ?? originalRequest.Model, + stop_reason = GetClaudeStopReason(openAIResponse.Choices?.FirstOrDefault()?.FinishReason), + stop_sequence = "", + content = [] + }; + + if (openAIResponse.Choices is { Count: > 0 }) + { + var choice = openAIResponse.Choices.First(); + var contents = new List(); + + if (!string.IsNullOrEmpty(choice.Message.Content) && !string.IsNullOrEmpty(choice.Message.ReasoningContent)) + { + contents.Add(new AnthropicChatCompletionDtoContent + { + type = "thinking", + Thinking = choice.Message.ReasoningContent + }); + + contents.Add(new AnthropicChatCompletionDtoContent + { + type = "text", + text = choice.Message.Content + }); + } + else + { + // 处理思维内容 + if (!string.IsNullOrEmpty(choice.Message.ReasoningContent)) + contents.Add(new AnthropicChatCompletionDtoContent + { + type = "thinking", + Thinking = choice.Message.ReasoningContent + }); + + // 处理文本内容 + if (!string.IsNullOrEmpty(choice.Message.Content)) + contents.Add(new AnthropicChatCompletionDtoContent + { + type = "text", + text = choice.Message.Content + }); + } + + // 处理工具调用 + if (choice.Message.ToolCalls is { Count: > 0 }) + contents.AddRange(choice.Message.ToolCalls.Select(toolCall => new AnthropicChatCompletionDtoContent + { + type = "tool_use", id = toolCall.Id, name = toolCall.Function?.Name, + input = JsonSerializer.Deserialize(toolCall.Function?.Arguments ?? "{}") + })); + + claudeResponse.content = contents.ToArray(); + } + + // 处理使用情况统计 - 确保始终提供Usage信息 + claudeResponse.Usage = new AnthropicCompletionDtoUsage + { + InputTokens = openAIResponse.Usage?.PromptTokens ?? 0, + OutputTokens = (int?)(openAIResponse.Usage?.CompletionTokens ?? 0), + CacheCreationInputTokens = openAIResponse.Usage?.PromptTokensDetails?.CachedTokens ?? 0, + CacheReadInputTokens = openAIResponse.Usage?.PromptTokensDetails?.CachedTokens ?? 0 + }; + + return claudeResponse; + } + + + /// + /// 转换Anthropic工具选择为Thor工具选择 + /// + public static ThorToolChoice ConvertAnthropicToolChoiceToThor(AnthropicTooChoiceInput anthropicToolChoice) + { + return new ThorToolChoice + { + Type = anthropicToolChoice.Type ?? "auto", + Function = anthropicToolChoice.Name != null + ? new ThorToolChoiceFunctionTool { Name = anthropicToolChoice.Name } + : null + }; + } +} \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/ThorJsonSerializer.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/ThorJsonSerializer.cs new file mode 100644 index 00000000..d934db50 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/Anthropic/ThorJsonSerializer.cs @@ -0,0 +1,14 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic; + +public static class ThorJsonSerializer +{ + public static JsonSerializerOptions DefaultOptions => new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; +} \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/OpenAi/ThorChatCompletionsRequest.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/OpenAi/ThorChatCompletionsRequest.cs index be52fb6a..4346796c 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/OpenAi/ThorChatCompletionsRequest.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/OpenAi/ThorChatCompletionsRequest.cs @@ -279,13 +279,10 @@ public class ThorChatCompletionsRequest [JsonPropertyName("thinking")] public ThorChatClaudeThinking? Thinking { get; set; } - /// - /// 参数验证 - /// - /// - /// - public IEnumerable Validate() - { - throw new NotImplementedException(); - } + [JsonPropertyName("enable_thinking")] public bool? EnableThinking { get; set; } + + [JsonPropertyName("web_search_options")] + public ThorChatWebSearchOptions? WebSearchOptions { get; set; } = null; + + [JsonPropertyName("reasoning_effort")] public string? ReasoningEffort { get; set; } } \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/OpenAi/ThorChatWebSearchOptions.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/OpenAi/ThorChatWebSearchOptions.cs new file mode 100644 index 00000000..6186cd98 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/OpenAi/ThorChatWebSearchOptions.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; + +namespace Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi; + +public class ThorChatWebSearchOptions +{ + [JsonPropertyName("search_context_size")] + public string? SearchContextSize { get; set; } + + [JsonPropertyName("user_location")] + public ThorUserLocation? UserLocation { get; set; } +} + +public sealed class ThorUserLocation +{ + [JsonPropertyName("type")] public required string Type { get; set; } + + [JsonPropertyName("approximate")] + public ThorUserLocationApproximate? Approximate { get; set; } +} + +public sealed class ThorUserLocationApproximate +{ + [JsonPropertyName("city")] + public string? City { get; set; } + + [JsonPropertyName("country")] + public string? Country { get; set; } + + [JsonPropertyName("region")] + public string? Region { get; set; } + + [JsonPropertyName("timezone")] + public string? Timezone { get; set; } +} \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/OpenAi/ThorToolFunctionPropertyDefinition.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/OpenAi/ThorToolFunctionPropertyDefinition.cs index 48c87d2d..185d4fbb 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/OpenAi/ThorToolFunctionPropertyDefinition.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Dtos/OpenAi/ThorToolFunctionPropertyDefinition.cs @@ -1,4 +1,5 @@ -using System.Text.Json.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi; @@ -25,56 +26,96 @@ public class ThorToolFunctionPropertyDefinition /// 表示字符串类型的函数对象 /// String, + /// /// 表示整数类型的函数对象 /// Integer, + /// /// 表示数字(包括浮点数等)类型的函数对象 /// Number, + /// /// 表示对象类型的函数对象 /// Object, + /// /// 表示数组类型的函数对象 /// Array, + /// /// 表示布尔类型的函数对象 /// Boolean, + /// /// 表示空值类型的函数对象 /// Null } + public string typeStr = "object"; + + public string[] Types; + /// /// 必填的。函数参数对象类型。默认值为“object”。 /// [JsonPropertyName("type")] - public object Type { get; set; } = "object"; + public object Type + { + get + { + if (Types is { Length: > 0 }) + { + return Types; + } + + return typeStr; + } + set + { + if (value is JsonElement str) + { + switch (str.ValueKind) + { + case JsonValueKind.String: + typeStr = value?.ToString(); + break; + case JsonValueKind.Array: + Types = JsonSerializer.Deserialize(value?.ToString()); + break; + } + } + else + { + typeStr = value?.ToString(); + } + } + } /// /// 可选。“函数参数”列表,作为从参数名称映射的字典 /// 对于描述类型的对象,可能还有可能的枚举值等等。 /// [JsonPropertyName("properties")] - public IDictionary? Properties { get; set; } + public IDictionary? Properties { get; set; } /// /// 可选。列出必需的“function arguments”列表。 /// [JsonPropertyName("required")] - public List? Required { get; set; } + public string[]? Required { get; set; } /// /// 可选。是否允许附加属性。默认值为true。 /// [JsonPropertyName("additionalProperties")] - public bool? AdditionalProperties { get; set; } + public object? AdditionalProperties { get; set; } /// /// 可选。参数描述。 @@ -219,11 +260,12 @@ public class ThorToolFunctionPropertyDefinition /// /// /// - public static ThorToolFunctionPropertyDefinition DefineObject(IDictionary? properties, - List? required, - bool? additionalProperties, - string? description, - List? @enum) + public static ThorToolFunctionPropertyDefinition DefineObject( + IDictionary? properties, + string[]? required, + object? additionalProperties, + string? description, + List? @enum) { return new ThorToolFunctionPropertyDefinition { @@ -242,7 +284,6 @@ public class ThorToolFunctionPropertyDefinition /// /// 要转换的类型 /// 给定类型的字符串表示形式 - public static string ConvertTypeToString(FunctionObjectTypes type) { return type switch diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Enums/ModelTypeEnum.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Enums/ModelTypeEnum.cs index 1e4fbd33..3bfbdebe 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Enums/ModelTypeEnum.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain.Shared/Enums/ModelTypeEnum.cs @@ -4,5 +4,6 @@ public enum ModelTypeEnum { Chat = 0, Image = 1, - Embedding = 2 + Embedding = 2, + PremiumChat = 3 } \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/IAnthropicChatCompletionService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/IAnthropicChatCompletionService.cs new file mode 100644 index 00000000..cf5ba6f2 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/IAnthropicChatCompletionService.cs @@ -0,0 +1,29 @@ +using Yi.Framework.AiHub.Domain.Shared.Dtos; +using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic; + +namespace Yi.Framework.AiHub.Domain.AiGateWay; + +public interface IAnthropicChatCompletionService +{ + /// + /// 非流式对话补全 + /// + /// 对话补全请求参数对象 + /// 平台参数对象 + /// 取消令牌 + /// + Task ChatCompletionsAsync(AiModelDescribe aiModelDescribe, + AnthropicInput request, + CancellationToken cancellationToken = default); + + /// + /// 流式对话补全 + /// + /// 对话补全请求参数对象 + /// 平台参数对象 + /// 取消令牌 + /// + IAsyncEnumerable<(string, AnthropicStreamDto?)> StreamChatCompletionsAsync(AiModelDescribe aiModelDescribe, + AnthropicInput request, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/ISpecialCompatible.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/ISpecialCompatible.cs index b1ddec98..9895f220 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/ISpecialCompatible.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/ISpecialCompatible.cs @@ -1,8 +1,10 @@ -using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi; +using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic; +using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi; namespace Yi.Framework.AiHub.Domain.AiGateWay; public interface ISpecialCompatible { public void Compatible(ThorChatCompletionsRequest request); + public void AnthropicCompatible(AnthropicInput request); } \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorClaude/Chats/AnthropicChatCompletionsService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorClaude/Chats/AnthropicChatCompletionsService.cs new file mode 100644 index 00000000..47746cdb --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorClaude/Chats/AnthropicChatCompletionsService.cs @@ -0,0 +1,168 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Yi.Framework.AiHub.Domain.Shared.Dtos; +using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic; +using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi; + +namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorClaude.Chats; + +public class AnthropicChatCompletionsService(IHttpClientFactory httpClientFactory,ILogger logger) + : IAnthropicChatCompletionService +{ + public async Task ChatCompletionsAsync(AiModelDescribe options, AnthropicInput input, + CancellationToken cancellationToken = default) + { + using var openai = + Activity.Current?.Source.StartActivity("Claudia 对话补全"); + + if (string.IsNullOrEmpty(options.Endpoint)) + { + options.Endpoint = "https://api.anthropic.com/"; + } + + var client = httpClientFactory.CreateClient(); + + var headers = new Dictionary + { + { "x-api-key", options.ApiKey }, + { "authorization", "Bearer " + options.ApiKey }, + { "anthropic-version", "2023-06-01" } + }; + + + bool isThink = input.Model.EndsWith("-thinking"); + input.Model = input.Model.Replace("-thinking", string.Empty); + + if (input.MaxTokens is < 2048) + { + input.MaxTokens = 2048; + } + + if (isThink && input.Thinking is null) + { + input.Thinking = new AnthropicThinkingInput() + { + Type = "enabled", + BudgetTokens = 4000 + }; + } + + if (input.Thinking is not null && input.Thinking.BudgetTokens > 0 && input.MaxTokens != null) + { + if (input.Thinking.BudgetTokens > input.MaxTokens) + { + input.Thinking.BudgetTokens = input.MaxTokens.Value - 1; + if (input.Thinking.BudgetTokens > 63999) + { + input.Thinking.BudgetTokens = 63999; + } + } + } + + var response = + await client.PostJsonAsync(options.Endpoint.TrimEnd('/') + "/v1/messages", input, string.Empty, headers); + + openai?.SetTag("Model", input.Model); + openai?.SetTag("Response", response.StatusCode.ToString()); + + // 大于等于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 Exception("OpenAI对话异常" + response.StatusCode.ToString()); + } + + var value = + await response.Content.ReadFromJsonAsync(ThorJsonSerializer.DefaultOptions, + cancellationToken: cancellationToken); + + return value; + } + + public async IAsyncEnumerable<(string, AnthropicStreamDto?)> StreamChatCompletionsAsync(AiModelDescribe options, AnthropicInput input, + CancellationToken cancellationToken = default) + { + using var openai = + Activity.Current?.Source.StartActivity("Claudia 对话补全"); + + if (string.IsNullOrEmpty(options.Endpoint)) + { + options.Endpoint = "https://api.anthropic.com/"; + } + + var client = HttpClientFactory.GetHttpClient(options.Endpoint); + + var headers = new Dictionary + { + { "x-api-key", options.ApiKey }, + { "authorization", options.ApiKey }, + { "anthropic-version", "2023-06-01" } + }; + + var isThinking = input.Model.EndsWith("thinking"); + input.Model = input.Model.Replace("-thinking", string.Empty); + + var response = await client.HttpRequestRaw(options.Endpoint.TrimEnd('/') + "/v1/messages", input, string.Empty, + headers); + + openai?.SetTag("Model", input.Model); + openai?.SetTag("Response", response.StatusCode.ToString()); + + // 大于等于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 Exception("OpenAI对话异常" + response.StatusCode); + } + + using var stream = new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken)); + + using StreamReader reader = new(await response.Content.ReadAsStreamAsync(cancellationToken)); + string? line = string.Empty; + + string? data = null; + string eventType = string.Empty; + + 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 Exception("OpenAI对话异常" + line); + } + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + if (line.StartsWith("event:")) + { + eventType = line; + continue; + } + + if (!line.StartsWith(OpenAIConstant.Data)) continue; + + data = line[OpenAIConstant.Data.Length..].Trim(); + + var result = JsonSerializer.Deserialize(data, + ThorJsonSerializer.DefaultOptions); + + yield return (eventType, result); + } + } +} \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorCustomOpenAI/Chats/CustomOpenAIAnthropicChatCompletionsService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorCustomOpenAI/Chats/CustomOpenAIAnthropicChatCompletionsService.cs new file mode 100644 index 00000000..04f06d66 --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorCustomOpenAI/Chats/CustomOpenAIAnthropicChatCompletionsService.cs @@ -0,0 +1,313 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Volo.Abp.DependencyInjection; +using Yi.Framework.AiHub.Domain.Shared.Dtos; +using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic; + +namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorCustomOpenAI.Chats; + +/// +/// OpenAI到Claude适配器服务 +/// 将Claude格式的请求转换为OpenAI格式,然后将OpenAI的响应转换为Claude格式 +/// +public class CustomOpenAIAnthropicChatCompletionsService( + IAbpLazyServiceProvider serviceProvider, + ILogger logger) + : IAnthropicChatCompletionService +{ + private IChatCompletionService GetChatCompletionService() + { + return serviceProvider.GetRequiredKeyedService(nameof(OpenAiChatCompletionsService)); + } + + public async Task ChatCompletionsAsync(AiModelDescribe aiModelDescribe, + AnthropicInput request, + CancellationToken cancellationToken = default) + { + // 转换请求格式:Claude -> OpenAI + var openAIRequest = AnthropicToOpenAi.ConvertAnthropicToOpenAi(request); + + if (openAIRequest.Model.StartsWith("gpt-5")) + { + openAIRequest.MaxCompletionTokens = request.MaxTokens; + openAIRequest.MaxTokens = null; + } + else if (openAIRequest.Model.StartsWith("o3-mini") || openAIRequest.Model.StartsWith("o4-mini")) + { + openAIRequest.MaxCompletionTokens = request.MaxTokens; + openAIRequest.MaxTokens = null; + openAIRequest.Temperature = null; + } + + // 调用OpenAI服务 + var openAIResponse = + await GetChatCompletionService().CompleteChatAsync(aiModelDescribe,openAIRequest, cancellationToken); + + // 转换响应格式:OpenAI -> Claude + var claudeResponse = AnthropicToOpenAi.ConvertOpenAIToClaude(openAIResponse, request); + + return claudeResponse; + } + + public async IAsyncEnumerable<(string, AnthropicStreamDto?)> StreamChatCompletionsAsync(AiModelDescribe aiModelDescribe, + AnthropicInput request, + CancellationToken cancellationToken = default) + { + var openAIRequest = AnthropicToOpenAi.ConvertAnthropicToOpenAi(request); + openAIRequest.Stream = true; + + if (openAIRequest.Model.StartsWith("gpt-5")) + { + openAIRequest.MaxCompletionTokens = request.MaxTokens; + openAIRequest.MaxTokens = null; + } + else if (openAIRequest.Model.StartsWith("o3-mini") || openAIRequest.Model.StartsWith("o4-mini")) + { + openAIRequest.MaxCompletionTokens = request.MaxTokens; + openAIRequest.MaxTokens = null; + openAIRequest.Temperature = null; + } + + var messageId = Guid.NewGuid().ToString(); + var hasStarted = false; + var hasTextContentBlockStarted = false; + var hasThinkingContentBlockStarted = false; + var toolBlocksStarted = new Dictionary(); // 使用索引而不是ID + var toolCallIds = new Dictionary(); // 存储每个索引对应的ID + var toolCallIndexToBlockIndex = new Dictionary(); // 工具调用索引到块索引的映射 + var accumulatedUsage = new AnthropicCompletionDtoUsage(); + var isFinished = false; + var currentContentBlockType = ""; // 跟踪当前内容块类型 + var currentBlockIndex = 0; // 跟踪当前块索引 + var lastContentBlockType = ""; // 跟踪最后一个内容块类型,用于确定停止原因 + + await foreach (var openAIResponse in GetChatCompletionService().CompleteChatStreamAsync(aiModelDescribe,openAIRequest, + cancellationToken)) + { + // 发送message_start事件 + if (!hasStarted && openAIResponse.Choices?.Count > 0 && + openAIResponse.Choices.Any(x => x.Delta.ToolCalls?.Count > 0) == false) + { + hasStarted = true; + var messageStartEvent = AnthropicToOpenAi.CreateMessageStartEvent(messageId, request.Model); + yield return ("message_start", messageStartEvent); + } + + // 更新使用情况统计 + if (openAIResponse.Usage != null) + { + // 使用最新的token计数(OpenAI通常在最后的响应中提供完整的统计) + if (openAIResponse.Usage.PromptTokens.HasValue) + { + accumulatedUsage.InputTokens = openAIResponse.Usage.PromptTokens.Value; + } + + if (openAIResponse.Usage.CompletionTokens.HasValue) + { + accumulatedUsage.OutputTokens = (int)openAIResponse.Usage.CompletionTokens.Value; + } + + if (openAIResponse.Usage.PromptTokensDetails?.CachedTokens.HasValue == true) + { + accumulatedUsage.CacheReadInputTokens = + openAIResponse.Usage.PromptTokensDetails.CachedTokens.Value; + } + + // 记录调试信息 + logger.LogDebug("OpenAI Usage更新: Input={InputTokens}, Output={OutputTokens}, CacheRead={CacheRead}", + accumulatedUsage.InputTokens, accumulatedUsage.OutputTokens, + accumulatedUsage.CacheReadInputTokens); + } + + if (openAIResponse.Choices is { Count: > 0 }) + { + var choice = openAIResponse.Choices.First(); + + // 处理内容 + if (!string.IsNullOrEmpty(choice.Delta?.Content)) + { + // 如果当前有其他类型的内容块在运行,先结束它们 + if (currentContentBlockType != "text" && !string.IsNullOrEmpty(currentContentBlockType)) + { + var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent(); + stopEvent.Index = currentBlockIndex; + yield return ("content_block_stop", stopEvent); + currentBlockIndex++; // 切换内容块时增加索引 + currentContentBlockType = ""; + } + + // 发送content_block_start事件(仅第一次) + if (!hasTextContentBlockStarted || currentContentBlockType != "text") + { + hasTextContentBlockStarted = true; + currentContentBlockType = "text"; + lastContentBlockType = "text"; + var contentBlockStartEvent = AnthropicToOpenAi.CreateContentBlockStartEvent(); + contentBlockStartEvent.Index = currentBlockIndex; + yield return ("content_block_start", + contentBlockStartEvent); + } + + // 发送content_block_delta事件 + var contentDeltaEvent = AnthropicToOpenAi.CreateContentBlockDeltaEvent(choice.Delta.Content); + contentDeltaEvent.Index = currentBlockIndex; + yield return ("content_block_delta", + contentDeltaEvent); + } + + // 处理工具调用 + if (choice.Delta?.ToolCalls is { Count: > 0 }) + { + foreach (var toolCall in choice.Delta.ToolCalls) + { + var toolCallIndex = toolCall.Index; // 使用索引来标识工具调用 + + // 发送tool_use content_block_start事件 + if (toolBlocksStarted.TryAdd(toolCallIndex, true)) + { + // 如果当前有文本或thinking内容块在运行,先结束它们 + if (currentContentBlockType == "text" || currentContentBlockType == "thinking") + { + var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent(); + stopEvent.Index = currentBlockIndex; + yield return ("content_block_stop", stopEvent); + currentBlockIndex++; // 增加块索引 + } + // 如果当前有其他工具调用在运行,也需要结束它们 + else if (currentContentBlockType == "tool_use") + { + var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent(); + stopEvent.Index = currentBlockIndex; + yield return ("content_block_stop", stopEvent); + currentBlockIndex++; // 增加块索引 + } + + currentContentBlockType = "tool_use"; + lastContentBlockType = "tool_use"; + + // 为此工具调用分配一个新的块索引 + toolCallIndexToBlockIndex[toolCallIndex] = currentBlockIndex; + + // 保存工具调用的ID(如果有的话) + if (!string.IsNullOrEmpty(toolCall.Id)) + { + toolCallIds[toolCallIndex] = toolCall.Id; + } + else if (!toolCallIds.ContainsKey(toolCallIndex)) + { + // 如果没有ID且之前也没有保存过,生成一个新的ID + toolCallIds[toolCallIndex] = Guid.NewGuid().ToString(); + } + + var toolBlockStartEvent = AnthropicToOpenAi.CreateToolBlockStartEvent( + toolCallIds[toolCallIndex], + toolCall.Function?.Name); + toolBlockStartEvent.Index = currentBlockIndex; + yield return ("content_block_start", + toolBlockStartEvent); + } + + // 如果有增量的参数,发送content_block_delta事件 + if (!string.IsNullOrEmpty(toolCall.Function?.Arguments)) + { + var toolDeltaEvent = + AnthropicToOpenAi.CreateToolBlockDeltaEvent(toolCall.Function.Arguments); + // 使用该工具调用对应的块索引 + toolDeltaEvent.Index = toolCallIndexToBlockIndex[toolCallIndex]; + yield return ("content_block_delta", + toolDeltaEvent); + } + } + } + + // 处理推理内容 + if (!string.IsNullOrEmpty(choice.Delta?.ReasoningContent)) + { + // 如果当前有其他类型的内容块在运行,先结束它们 + if (currentContentBlockType != "thinking" && !string.IsNullOrEmpty(currentContentBlockType)) + { + var stopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent(); + stopEvent.Index = currentBlockIndex; + yield return ("content_block_stop", stopEvent); + currentBlockIndex++; // 增加块索引 + currentContentBlockType = ""; + } + + // 对于推理内容,也需要发送对应的事件 + if (!hasThinkingContentBlockStarted || currentContentBlockType != "thinking") + { + hasThinkingContentBlockStarted = true; + currentContentBlockType = "thinking"; + lastContentBlockType = "thinking"; + var thinkingBlockStartEvent = AnthropicToOpenAi.CreateThinkingBlockStartEvent(); + thinkingBlockStartEvent.Index = currentBlockIndex; + yield return ("content_block_start", + thinkingBlockStartEvent); + } + + var thinkingDeltaEvent = + AnthropicToOpenAi.CreateThinkingBlockDeltaEvent(choice.Delta.ReasoningContent); + thinkingDeltaEvent.Index = currentBlockIndex; + yield return ("content_block_delta", + thinkingDeltaEvent); + } + + // 处理结束 + if (!string.IsNullOrEmpty(choice.FinishReason) && !isFinished) + { + isFinished = true; + + // 发送content_block_stop事件(如果有活跃的内容块) + if (!string.IsNullOrEmpty(currentContentBlockType)) + { + var contentBlockStopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent(); + contentBlockStopEvent.Index = currentBlockIndex; + yield return ("content_block_stop", + contentBlockStopEvent); + } + + // 发送message_delta事件 + var messageDeltaEvent = AnthropicToOpenAi.CreateMessageDeltaEvent( + AnthropicToOpenAi.GetStopReasonByLastContentType(choice.FinishReason, lastContentBlockType), + accumulatedUsage); + + // 记录最终Usage统计 + logger.LogDebug( + "流式响应结束,最终Usage: Input={InputTokens}, Output={OutputTokens}, CacheRead={CacheRead}", + accumulatedUsage.InputTokens, accumulatedUsage.OutputTokens, + accumulatedUsage.CacheReadInputTokens); + + yield return ("message_delta", + messageDeltaEvent); + + // 发送message_stop事件 + var messageStopEvent = AnthropicToOpenAi.CreateMessageStopEvent(); + yield return ("message_stop", + messageStopEvent); + } + } + } + + // 确保流正确结束 + if (!isFinished) + { + if (!string.IsNullOrEmpty(currentContentBlockType)) + { + var contentBlockStopEvent = AnthropicToOpenAi.CreateContentBlockStopEvent(); + contentBlockStopEvent.Index = currentBlockIndex; + yield return ("content_block_stop", + contentBlockStopEvent); + } + + var messageDeltaEvent = + AnthropicToOpenAi.CreateMessageDeltaEvent( + AnthropicToOpenAi.GetStopReasonByLastContentType("end_turn", lastContentBlockType), + accumulatedUsage); + yield return ("message_delta", messageDeltaEvent); + + var messageStopEvent = AnthropicToOpenAi.CreateMessageStopEvent(); + yield return ("message_stop", + messageStopEvent); + } + } +} \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorCustomOpenAI/Chats/OpenAIChatCompletionsService.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorCustomOpenAI/Chats/OpenAIChatCompletionsService.cs new file mode 100644 index 00000000..32094c8a --- /dev/null +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/Impl/ThorCustomOpenAI/Chats/OpenAIChatCompletionsService.cs @@ -0,0 +1,167 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Yi.Framework.AiHub.Domain.AiGateWay.Exceptions; +using Yi.Framework.AiHub.Domain.Shared.Dtos; +using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi; + +namespace Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorCustomOpenAI.Chats; + +public sealed class OpenAiChatCompletionsService(ILogger logger,IHttpClientFactory httpClientFactory) + : IChatCompletionService +{ + public async IAsyncEnumerable CompleteChatStreamAsync(AiModelDescribe options, + ThorChatCompletionsRequest chatCompletionCreate, + CancellationToken cancellationToken) + { + using var openai = + Activity.Current?.Source.StartActivity("OpenAI 对话流式补全"); + + var response = await httpClientFactory.CreateClient().HttpRequestRaw( + options?.Endpoint.TrimEnd('/') + "/chat/completions", + 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.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().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(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 CompleteChatAsync(AiModelDescribe options, + ThorChatCompletionsRequest chatCompletionCreate, + CancellationToken cancellationToken) + { + using var openai = + Activity.Current?.Source.StartActivity("OpenAI 对话补全"); + + var response = await HttpClientFactory.GetHttpClient(options.Endpoint).PostJsonAsync( + options?.Endpoint.TrimEnd('/') + "/chat/completions", + 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( + cancellationToken: cancellationToken).ConfigureAwait(false); + + return result; + } +} \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/SpecialCompatible.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/SpecialCompatible.cs index 4363770f..f7506e93 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/SpecialCompatible.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/SpecialCompatible.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Options; using Volo.Abp.DependencyInjection; +using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic; using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi; namespace Yi.Framework.AiHub.Domain.AiGateWay; @@ -20,4 +21,12 @@ public class SpecialCompatible : ISpecialCompatible,ISingletonDependency handle(request); } } + + public void AnthropicCompatible(AnthropicInput request) + { + foreach (var handle in _options.Value.AnthropicHandles) + { + handle(request); + } + } } \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/SpecialCompatibleOptions.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/SpecialCompatibleOptions.cs index b3f9d131..5e133004 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/SpecialCompatibleOptions.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/AiGateWay/SpecialCompatibleOptions.cs @@ -1,8 +1,10 @@ -using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi; +using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic; +using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi; namespace Yi.Framework.AiHub.Domain.AiGateWay; public class SpecialCompatibleOptions { public List> Handles { get; set; } = new(); + public List> AnthropicHandles { get; set; } = new(); } \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs index e8ef8327..d753cf17 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/Managers/AiGateWayManager.cs @@ -13,11 +13,14 @@ using Yi.Framework.AiHub.Domain.AiGateWay; using Yi.Framework.AiHub.Domain.AiGateWay.Exceptions; using Yi.Framework.AiHub.Domain.Entities.Model; using Yi.Framework.AiHub.Domain.Shared.Dtos; +using Yi.Framework.AiHub.Domain.Shared.Dtos.Anthropic; using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi; using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Embeddings; using Yi.Framework.AiHub.Domain.Shared.Dtos.OpenAi.Images; using Yi.Framework.Core.Extensions; using Yi.Framework.SqlSugarCore.Abstractions; +using JsonSerializer = System.Text.Json.JsonSerializer; +using ThorJsonSerializer = Yi.Framework.AiHub.Domain.AiGateWay.ThorJsonSerializer; namespace Yi.Framework.AiHub.Domain.Managers; @@ -394,7 +397,7 @@ public class AiGateWayManager : DomainService var usage = new ThorUsageResponse() { - PromptTokens = stream.Usage?.PromptTokens??0, + PromptTokens = stream.Usage?.PromptTokens ?? 0, InputTokens = stream.Usage?.InputTokens ?? 0, CompletionTokens = 0, TotalTokens = stream.Usage?.InputTokens ?? 0 @@ -441,4 +444,210 @@ public class AiGateWayManager : DomainService throw new UserFriendlyException(errorContent); } } + + + /// + /// Anthropic聊天完成-流式 + /// + /// + /// + /// + public async IAsyncEnumerable<(string, AnthropicStreamDto?)> AnthropicCompleteChatStreamAsync( + AnthropicInput request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + _specialCompatible.AnthropicCompatible(request); + var modelDescribe = await GetModelAsync(request.Model); + var chatService = + LazyServiceProvider.GetRequiredKeyedService(modelDescribe.HandlerName); + + await foreach (var result in chatService.StreamChatCompletionsAsync(modelDescribe, request, cancellationToken)) + { + yield return result; + } + } + + + /// + /// Anthropic聊天完成-非流式 + /// + /// + /// + /// + /// + /// + /// + public async Task AnthropicCompleteChatForStatisticsAsync(HttpContext httpContext, + AnthropicInput request, + Guid? userId = null, + Guid? sessionId = null, + CancellationToken cancellationToken = default) + { + _specialCompatible.AnthropicCompatible(request); + var response = httpContext.Response; + // 设置响应头,声明是 json + //response.ContentType = "application/json; charset=UTF-8"; + var modelDescribe = await GetModelAsync(request.Model); + var chatService = + LazyServiceProvider.GetRequiredKeyedService(modelDescribe.HandlerName); + var data = await chatService.ChatCompletionsAsync(modelDescribe, request, cancellationToken); + if (userId is not null) + { + await _aiMessageManager.CreateUserMessageAsync(userId.Value, sessionId, + new MessageInputDto + { + Content = request.Messages?.FirstOrDefault()?.Content ?? string.Empty, + ModelId = request.Model, + TokenUsage = data.TokenUsage, + }); + + await _aiMessageManager.CreateSystemMessageAsync(userId.Value, sessionId, + new MessageInputDto + { + Content = data.content?.FirstOrDefault()?.text, + ModelId = request.Model, + TokenUsage = data.TokenUsage + }); + + await _usageStatisticsManager.SetUsageAsync(userId.Value, request.Model, data.TokenUsage); + } + + await response.WriteAsJsonAsync(data, cancellationToken); + } + + /// + /// Anthropic聊天完成-缓存处理 + /// + /// + /// + /// + /// + /// + /// + public async Task AnthropicCompleteChatStreamForStatisticsAsync( + HttpContext httpContext, + AnthropicInput request, + Guid? userId = null, + Guid? sessionId = null, + CancellationToken cancellationToken = default) + { + var response = httpContext.Response; + // 设置响应头,声明是 SSE 流 + response.ContentType = "text/event-stream;charset=utf-8;"; + response.Headers.TryAdd("Cache-Control", "no-cache"); + response.Headers.TryAdd("Connection", "keep-alive"); + + + var gateWay = LazyServiceProvider.GetRequiredService(); + var completeChatResponse = gateWay.AnthropicCompleteChatStreamAsync(request, cancellationToken); + ThorUsageResponse? tokenUsage = null; + StringBuilder backupSystemContent = new StringBuilder(); + try + { + await foreach (var responseResult in completeChatResponse) + { + tokenUsage = responseResult.Item2?.TokenUsage; + backupSystemContent.Append(responseResult.Item2?.Delta?.Text); + await WriteAsEventStreamDataAsync(httpContext, responseResult.Item1, responseResult.Item2, + cancellationToken); + } + } + catch (Exception e) + { + _logger.LogError(e, $"Ai对话异常"); + var errorContent = $"对话Ai异常,异常信息:\n当前Ai模型:{request.Model}\n异常信息:{e.Message}\n异常堆栈:{e}"; + var model = new AnthropicStreamDto + { + Message = new AnthropicChatCompletionDto + { + content = + [ + new AnthropicChatCompletionDtoContent + { + text = errorContent, + } + ], + }, + Error = new AnthropicStreamErrorDto + { + Type = null, + Message = errorContent + } + }; + var message = JsonConvert.SerializeObject(model, new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver() + }); + await response.WriteAsJsonAsync(message, ThorJsonSerializer.DefaultOptions); + } + + await _aiMessageManager.CreateUserMessageAsync(userId, sessionId, + new MessageInputDto + { + Content = request.Messages?.LastOrDefault()?.Content ?? string.Empty, + ModelId = request.Model, + TokenUsage = tokenUsage, + }); + + await _aiMessageManager.CreateSystemMessageAsync(userId, sessionId, + new MessageInputDto + { + Content = backupSystemContent.ToString(), + ModelId = request.Model, + TokenUsage = tokenUsage + }); + + await _usageStatisticsManager.SetUsageAsync(userId, request.Model, tokenUsage); + } + + #region Anthropic格式Http响应 + + private static readonly byte[] EventPrefix = "event: "u8.ToArray(); + private static readonly byte[] DataPrefix = "data: "u8.ToArray(); + private static readonly byte[] NewLine = "\n"u8.ToArray(); + private static readonly byte[] DoubleNewLine = "\n\n"u8.ToArray(); + + /// + /// 使用 JsonSerializer.SerializeAsync 直接序列化到响应流 + /// + private static async ValueTask WriteAsEventStreamDataAsync( + HttpContext context, + string @event, + T value, + CancellationToken cancellationToken = default) + where T : class + { + var response = context.Response; + var bodyStream = response.Body; + // 确保 SSE Header 已经设置好 + // e.g. Content-Type: text/event-stream; charset=utf-8 + await response.StartAsync(cancellationToken).ConfigureAwait(false); + // 写事件类型 + await bodyStream.WriteAsync(EventPrefix, cancellationToken).ConfigureAwait(false); + await WriteUtf8StringAsync(bodyStream, @event.Trim(), cancellationToken).ConfigureAwait(false); + await bodyStream.WriteAsync(NewLine, cancellationToken).ConfigureAwait(false); + // 写 data: + JSON + await bodyStream.WriteAsync(DataPrefix, cancellationToken).ConfigureAwait(false); + await JsonSerializer.SerializeAsync( + bodyStream, + value, + ThorJsonSerializer.DefaultOptions, + cancellationToken + ).ConfigureAwait(false); + // 事件结束 \n\n + await bodyStream.WriteAsync(DoubleNewLine, cancellationToken).ConfigureAwait(false); + // 及时把数据发送给客户端 + await bodyStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + + private static async ValueTask WriteUtf8StringAsync(Stream stream, string value, CancellationToken token) + { + if (string.IsNullOrEmpty(value)) + return; + var buffer = Encoding.UTF8.GetBytes(value); + await stream.WriteAsync(buffer, token).ConfigureAwait(false); + } + + #endregion } \ No newline at end of file diff --git a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/YiFrameworkAiHubDomainModule.cs b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/YiFrameworkAiHubDomainModule.cs index bf214692..106b72d0 100644 --- a/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/YiFrameworkAiHubDomainModule.cs +++ b/Yi.Abp.Net8/module/ai-hub/Yi.Framework.AiHub.Domain/YiFrameworkAiHubDomainModule.cs @@ -10,6 +10,8 @@ using Yi.Framework.AiHub.Domain.AiGateWay; using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorAzureDatabricks.Chats; using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorAzureOpenAI.Chats; using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorAzureOpenAI.Images; +using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorClaude.Chats; +using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorCustomOpenAI.Chats; using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorDeepSeek.Chats; using Yi.Framework.AiHub.Domain.AiGateWay.Impl.ThorSiliconFlow.Embeddings; using Yi.Framework.AiHub.Domain.Managers.Fuwuhao; @@ -31,21 +33,43 @@ namespace Yi.Framework.AiHub.Domain var configuration = context.Services.GetConfiguration(); var services = context.Services; - // Configure(configuration.GetSection("AiGateWay")); + #region OpenAi ChatCompletion + services.AddKeyedTransient( nameof(AzureOpenAiChatCompletionCompletionsService)); services.AddKeyedTransient( nameof(AzureDatabricksChatCompletionsService)); services.AddKeyedTransient( nameof(DeepSeekChatCompletionsService)); + services.AddKeyedTransient( + nameof(OpenAiChatCompletionsService)); + + #endregion + + #region Anthropic ChatCompletion + + services.AddKeyedTransient( + nameof(CustomOpenAIAnthropicChatCompletionsService)); + services.AddKeyedTransient( + nameof(AnthropicChatCompletionsService)); + + #endregion + + + #region Image services.AddKeyedTransient( nameof(AzureOpenAIServiceImageService)); + #endregion + + #region Embedding services.AddKeyedTransient( nameof(SiliconFlowTextEmbeddingService)); + #endregion + //ai模型特殊性兼容处理 Configure(options => { @@ -82,20 +106,18 @@ namespace Yi.Framework.AiHub.Domain request.MaxTokens = 16384; } }); + options.AnthropicHandles.add(request => { }); }); + //配置支付宝支付 var config = configuration.GetSection("Alipay").Get(); Factory.SetOptions(config); - + //配置服务号 Configure(configuration.GetSection("Fuwuhao")); services.AddHttpClient(); } - - public override async Task OnApplicationInitializationAsync(ApplicationInitializationContext context) - { - } } } \ No newline at end of file