From f8dcbf5ad1f6f0f2bca95d6f072ebb9f6accf521 Mon Sep 17 00:00:00 2001 From: orcadev Date: Sat, 15 Aug 2026 14:10:49 +0000 Subject: [PATCH] Add OrcaRouter as a named provider platform Adds a Thor.OrcaRouter extension mirroring the existing DeepSeek integration: a named OrcaRouter platform with base URL https://api.orcarouter.ai/v1, OpenAI-compatible chat completions, and a default model list. Wires it into Program.cs, the solution and both READMEs. Co-Authored-By: Claude --- README.md | 1 + README.zh-cn.md | 1 + Thor.sln | 7 + src/Thor.Service/Program.cs | 2 + src/Thor.Service/Thor.Service.csproj | 1 + .../Chats/OrcaRouterChatCompletionsService.cs | 179 ++++++++++++++++++ .../OrcaRouterServiceCollectionExtensions.cs | 51 +++++ .../OrcaRouterPlatformOptions.cs | 14 ++ .../Thor.OrcaRouter/Thor.OrcaRouter.csproj | 13 ++ 9 files changed, 269 insertions(+) create mode 100644 src/extensions/Thor.OrcaRouter/Chats/OrcaRouterChatCompletionsService.cs create mode 100644 src/extensions/Thor.OrcaRouter/Extensions/OrcaRouterServiceCollectionExtensions.cs create mode 100644 src/extensions/Thor.OrcaRouter/OrcaRouterPlatformOptions.cs create mode 100644 src/extensions/Thor.OrcaRouter/Thor.OrcaRouter.csproj diff --git a/README.md b/README.md index 4a7a6ca..d27676f 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 7850bb5..8c07f6d 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 82aa63a..b66cb4b 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 e41d5db..88715d6 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 91cc466..11307f6 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 0000000..c51b78b --- /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 0000000..f3106ea --- /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 0000000..3a78655 --- /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 0000000..3533833 --- /dev/null +++ b/src/extensions/Thor.OrcaRouter/Thor.OrcaRouter.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + -- Gitee