diff --git a/README.md b/README.md index 4a7a6ca4e5cb3977776a433a41bb01bf88b80c65..d27676f542d5cbe520a5aa7d010ebd8ad70f0f78 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ - ✅ **Kimi (Moonshot AI)** (Function Calling supported) - ✅ **DeepSeek** (Function Calling supported) - ✅ **Claude (Anthropic)** (Cache billing optimization supported) +- ✅ **OrcaRouter** (AI routing gateway for unified multi-model access) ### Chinese AI Models - ✅ **Baidu Wenxin Yiyan (ErnieBot)** (Function Calling supported) diff --git a/README.zh-cn.md b/README.zh-cn.md index 7850bb56770a7b6c9d484f975d8b2818d67ff851..8c07f6da09896fd63523ba36c3407f954945c7ee 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -53,6 +53,7 @@ - ✅ **Kimi (月之暗面)** (支持Function Calling) - ✅ **DeepSeek** (支持Function Calling) - ✅ **Claude (Anthropic)** (支持缓存计费优化) +- ✅ **OrcaRouter** (AI路由网关,统一接入多模型) ### 国内大模型 - ✅ **百度文心一言 (ErnieBot)** (支持Function Calling) diff --git a/Thor.sln b/Thor.sln index 82aa63a8a2e55a801f359c415fbb6919c2866494..b66cb4bc28ed496e69c033aa8e9a3c690e49ef64 100644 --- a/Thor.sln +++ b/Thor.sln @@ -92,6 +92,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Thor.SiliconFlow", "src\ext EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Thor.DeepSeek", "src\extensions\Thor.DeepSeek\Thor.DeepSeek.csproj", "{30D42224-8BC3-4CA8-81DC-349B59595A6B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Thor.OrcaRouter", "src\extensions\Thor.OrcaRouter\Thor.OrcaRouter.csproj", "{9FF41970-1305-4382-86A0-9B3810FC464E}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Thor.VolCenGine", "src\extensions\Thor.VolCenGine\Thor.VolCenGine.csproj", "{65B6C566-F252-499B-BF17-77C53537F766}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Thor.GCPClaude", "src\extensions\Thor.GCPClaude\Thor.GCPClaude.csproj", "{640AFAA2-E60E-478B-82ED-4C47BF6A5F12}" @@ -248,6 +250,10 @@ Global {30D42224-8BC3-4CA8-81DC-349B59595A6B}.Debug|Any CPU.Build.0 = Debug|Any CPU {30D42224-8BC3-4CA8-81DC-349B59595A6B}.Release|Any CPU.ActiveCfg = Release|Any CPU {30D42224-8BC3-4CA8-81DC-349B59595A6B}.Release|Any CPU.Build.0 = Release|Any CPU + {9FF41970-1305-4382-86A0-9B3810FC464E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9FF41970-1305-4382-86A0-9B3810FC464E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9FF41970-1305-4382-86A0-9B3810FC464E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9FF41970-1305-4382-86A0-9B3810FC464E}.Release|Any CPU.Build.0 = Release|Any CPU {65B6C566-F252-499B-BF17-77C53537F766}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {65B6C566-F252-499B-BF17-77C53537F766}.Debug|Any CPU.Build.0 = Debug|Any CPU {65B6C566-F252-499B-BF17-77C53537F766}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -309,6 +315,7 @@ Global {F4B508BD-C8C4-4502-A3CC-1A0D6B220BC7} = {294B7A34-48F5-43AB-A1D3-033480559E3A} {B0314829-D762-4DEE-A5ED-59384E7D8B80} = {294B7A34-48F5-43AB-A1D3-033480559E3A} {30D42224-8BC3-4CA8-81DC-349B59595A6B} = {294B7A34-48F5-43AB-A1D3-033480559E3A} + {9FF41970-1305-4382-86A0-9B3810FC464E} = {294B7A34-48F5-43AB-A1D3-033480559E3A} {65B6C566-F252-499B-BF17-77C53537F766} = {294B7A34-48F5-43AB-A1D3-033480559E3A} {640AFAA2-E60E-478B-82ED-4C47BF6A5F12} = {294B7A34-48F5-43AB-A1D3-033480559E3A} {99796C52-045C-4091-9134-51E56DC29CAE} = {294B7A34-48F5-43AB-A1D3-033480559E3A} diff --git a/src/Thor.Service/Program.cs b/src/Thor.Service/Program.cs index e41d5db7dea448d0172de36f647c7112ce1c0721..88715d65fdd3c6f395f63b17feefea88a5dc93e8 100644 --- a/src/Thor.Service/Program.cs +++ b/src/Thor.Service/Program.cs @@ -23,6 +23,7 @@ using Thor.Core.DataAccess; using Thor.Core.Extensions; using Thor.CustomOpenAI.Extensions; using Thor.DeepSeek.Extensions; +using Thor.OrcaRouter.Extensions; using Thor.Domain.Chats; using Thor.Domain.Users; using Thor.ErnieBot.Extensions; @@ -190,6 +191,7 @@ try .AddSiliconFlowService() .AddAzureDatabricksPlatform() .AddDeepSeekService() + .AddOrcaRouterService() .AddGeminiService(); builder.Services.AddScoped(); diff --git a/src/Thor.Service/Thor.Service.csproj b/src/Thor.Service/Thor.Service.csproj index 91cc466fab63789db1fd1155b116570402a267dd..11307f606231ab73782d1f845737a026cbe3c8e9 100644 --- a/src/Thor.Service/Thor.Service.csproj +++ b/src/Thor.Service/Thor.Service.csproj @@ -51,6 +51,7 @@ + diff --git a/src/extensions/Thor.OrcaRouter/Chats/OrcaRouterChatCompletionsService.cs b/src/extensions/Thor.OrcaRouter/Chats/OrcaRouterChatCompletionsService.cs new file mode 100644 index 0000000000000000000000000000000000000000..c51b78b53dbbb854a8e42e253b2693002aabbb44 --- /dev/null +++ b/src/extensions/Thor.OrcaRouter/Chats/OrcaRouterChatCompletionsService.cs @@ -0,0 +1,179 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Json; +using System.Runtime.CompilerServices; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Thor.Abstractions; +using Thor.Abstractions.Chats; +using Thor.Abstractions.Chats.Dtos; +using Thor.Abstractions.Exceptions; +using Thor.Abstractions.Extensions; + +namespace Thor.OrcaRouter.Chats; + +public sealed class OrcaRouterChatCompletionsService(ILogger logger) + : IThorChatCompletionsService +{ + public async Task ChatCompletionsAsync(ThorChatCompletionsRequest chatCompletionCreate, + ThorPlatformOptions? options = null, + CancellationToken cancellationToken = default) + { + using var openai = + Activity.Current?.Source.StartActivity("OpenAI 对话补全"); + + if (string.IsNullOrWhiteSpace(options.Address)) + { + options.Address = "https://api.orcarouter.ai"; + } + + var response = await HttpClientFactory.GetHttpClient(options.Address).PostJsonAsync( + options?.Address.TrimEnd('/') + "/v1/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对话异常 , StatusCode: {StatusCode} Response: {Response}", response.StatusCode, error); + + throw new BusinessException("OpenAI对话异常", response.StatusCode.ToString()); + } + + var result = + await response.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken).ConfigureAwait(false); + + return result; + } + + public async IAsyncEnumerable StreamChatCompletionsAsync( + ThorChatCompletionsRequest chatCompletionCreate, ThorPlatformOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(options.Address)) + { + options.Address = "https://api.orcarouter.ai"; + } + using var openai = + Activity.Current?.Source.StartActivity("OpenAI 对话流式补全"); + + var response = await HttpClientFactory.GetHttpClient(options.Address).HttpRequestRaw( + options?.Address.TrimEnd('/') + "/v1/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.PaymentRequired) + { + throw new PaymentRequiredException(); + } + + // 如果限流则抛出限流异常 + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + throw new ThorRateLimitException(); + } + + // 大于等于400的状态码都认为是异常 + if (response.StatusCode >= HttpStatusCode.BadRequest) + { + logger.LogError("OpenAI对话异常 , StatusCode: {StatusCode} ", response.StatusCode); + + throw new BusinessException("OpenAI对话异常", 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); + + var content = result?.Choices?.FirstOrDefault()?.Delta; + + if (first && string.IsNullOrWhiteSpace(content?.Content) && string.IsNullOrEmpty(content?.ReasoningContent)) + { + continue; + } + + if (first && content.Content == OpenAIConstant.ThinkStart) + { + isThink = true; + continue; + // 需要将content的内容转换到其他字段 + } + + if (isThink && content.Content == OpenAIConstant.ThinkEnd) + { + isThink = false; + // 需要将content的内容转换到其他字段 + continue; + } + + if (isThink) + { + // 需要将content的内容转换到其他字段 + foreach (var choice in result.Choices) + { + choice.Delta.ReasoningContent = choice.Delta.Content; + choice.Delta.Content = string.Empty; + } + } + first = false; + + yield return result; + } + } +} diff --git a/src/extensions/Thor.OrcaRouter/Extensions/OrcaRouterServiceCollectionExtensions.cs b/src/extensions/Thor.OrcaRouter/Extensions/OrcaRouterServiceCollectionExtensions.cs new file mode 100644 index 0000000000000000000000000000000000000000..f3106eacfcbff7e615c99600f905f18c0ea25609 --- /dev/null +++ b/src/extensions/Thor.OrcaRouter/Extensions/OrcaRouterServiceCollectionExtensions.cs @@ -0,0 +1,51 @@ +using System.Net.Http.Headers; +using Microsoft.Extensions.DependencyInjection; +using Thor.Abstractions; +using Thor.Abstractions.Chats; +using Thor.OrcaRouter.Chats; + +namespace Thor.OrcaRouter.Extensions; + +public static class OrcaRouterServiceCollectionExtensions +{ + public static IServiceCollection AddOrcaRouterService(this IServiceCollection services) + { + ThorGlobal.PlatformNames.Add(OrcaRouterPlatformOptions.PlatformName, OrcaRouterPlatformOptions.PlatformCode); + + ThorGlobal.ModelNames.Add(OrcaRouterPlatformOptions.PlatformCode, [ + "orcarouter/fusion", + "orcarouter/fusion-flash", + "orcarouter/fusion-mini", + "anthropic/claude-opus-4.8", + "anthropic/claude-sonnet-4.5", + "anthropic/claude-haiku-4.5", + "deepseek/deepseek-chat", + "deepseek/deepseek-reasoner", + "google/gemini-2.5-pro", + "google/gemini-2.5-flash", + "openai/gpt-4", + "openai/gpt-3.5-turbo", + "qwen/qwen3-max" + ]); + + services.AddKeyedSingleton(OrcaRouterPlatformOptions + .PlatformCode); + + services.AddHttpClient(OrcaRouterPlatformOptions.PlatformCode, + options => + { + options.Timeout = TimeSpan.FromMinutes(10); + + options.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Mozilla", "5.0")); + }) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(6), + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(6), + EnableMultipleHttp2Connections = true, + ConnectTimeout = TimeSpan.FromMinutes(6) + }); + + return services; + } +} diff --git a/src/extensions/Thor.OrcaRouter/OrcaRouterPlatformOptions.cs b/src/extensions/Thor.OrcaRouter/OrcaRouterPlatformOptions.cs new file mode 100644 index 0000000000000000000000000000000000000000..3a7865516d75385f0fb674c8d27eeeb93d2ba813 --- /dev/null +++ b/src/extensions/Thor.OrcaRouter/OrcaRouterPlatformOptions.cs @@ -0,0 +1,14 @@ +namespace Thor.OrcaRouter; + +public class OrcaRouterPlatformOptions +{ + /// + /// 平台名称 + /// + public const string PlatformName = "OrcaRouter"; + + /// + /// 平台编码 + /// + public const string PlatformCode = "OrcaRouter"; +} diff --git a/src/extensions/Thor.OrcaRouter/Thor.OrcaRouter.csproj b/src/extensions/Thor.OrcaRouter/Thor.OrcaRouter.csproj new file mode 100644 index 0000000000000000000000000000000000000000..3533833bbb5383fa50b99ff5bd972e702d883c34 --- /dev/null +++ b/src/extensions/Thor.OrcaRouter/Thor.OrcaRouter.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + +