From 970a946e522596b167668b7bd374d2bd203e8b69 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Thu, 17 Sep 2026 07:25:02 +0800 Subject: [PATCH 1/3] fix(llm): per-model protocol bindings for OpenCode Zen/Go presets The presets mapped both OpenCode gateways to LLMProvider.Anthropic with a single AnthropicMessages binding, but neither gateway is Anthropic-only: - Go serves /responses (grok-4.6, gpt-5.6-luna) and /chat/completions - Zen is per-model: claude-* -> messages, gpt-*/grok-* -> responses, gemini-* -> Google, everything else -> chat completions - LlmProviderPreset gains optional Bindings + ModelBindingRules (prefix -> binding) and CatalogIsEntitlement to mark subscription gateways - preset creation builds the extra bindings and assigns seeded models to the protocol matched by their name; Zen stays pay-per-token (no auto-seeded models) - Go seeds the official model list and refreshes its catalog add-only from /models, assigning discovered models through the same rules - AnthropicMessagesTransport applies x-opencode-session (per-chat when known), fixing Go's 400 MissingSessionID on the Anthropic-compatible path - IEditLLMConfHelper: EnsureBinding + AssignModelBinding helpers - Tests: LlmProviderCatalogTests, AnthropicTransportSessionTests, preset flow --- .../AI/LLM/AnthropicTransportSessionTests.cs | 29 ++++ .../Service/AI/LLM/LlmProviderCatalogTests.cs | 100 +++++++++++++ .../Service/AI/LLM/LlmProviderCatalog.cs | 115 +++++++++++++- .../Service/AI/LLM/LlmProviderRegistry.cs | 2 +- .../Transports/AnthropicMessagesTransport.cs | 17 ++- .../Manage/EditLLMConfTest.cs | 45 ++++++ .../Interface/Manage/IEditLLMConfHelper.cs | 9 ++ .../Service/Manage/EditLLMConfHelper.cs | 140 +++++++++++++++++- .../Service/Manage/EditLLMConfService.cs | 28 ++++ 9 files changed, 472 insertions(+), 13 deletions(-) create mode 100644 TelegramSearchBot.LLM.Test/Service/AI/LLM/AnthropicTransportSessionTests.cs create mode 100644 TelegramSearchBot.LLM.Test/Service/AI/LLM/LlmProviderCatalogTests.cs diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/AnthropicTransportSessionTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/AnthropicTransportSessionTests.cs new file mode 100644 index 00000000..379580a4 --- /dev/null +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/AnthropicTransportSessionTests.cs @@ -0,0 +1,29 @@ +using System.Linq; +using System.Net.Http; +using TelegramSearchBot.Service.AI.LLM.Transports; +using Xunit; + +namespace TelegramSearchBot.LLM.Test.Service.AI.LLM { + public class AnthropicTransportSessionTests { + [Fact] + public void CreateSessionHttpClient_AppliesStableSessionForOpenCodeEndpoints() { + using var client = AnthropicMessagesTransport.CreateSessionHttpClient("https://opencode.ai/zen/go/v1", 42); + + Assert.Equal("tsb-42", client.DefaultRequestHeaders.GetValues("x-opencode-session").Single()); + } + + [Fact] + public void CreateSessionHttpClient_UsesGlobalSessionWithoutChatId() { + using var client = AnthropicMessagesTransport.CreateSessionHttpClient("https://opencode.ai/zen/v1"); + + Assert.Equal("tsb-global", client.DefaultRequestHeaders.GetValues("x-opencode-session").Single()); + } + + [Fact] + public void CreateSessionHttpClient_LeavesOtherEndpointsUntouched() { + using var client = AnthropicMessagesTransport.CreateSessionHttpClient("https://api.anthropic.com/v1", 42); + + Assert.False(client.DefaultRequestHeaders.Contains("x-opencode-session")); + } + } +} diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/LlmProviderCatalogTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/LlmProviderCatalogTests.cs new file mode 100644 index 00000000..90a9238d --- /dev/null +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/LlmProviderCatalogTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Linq; +using TelegramSearchBot.Model.AI; +using TelegramSearchBot.Service.AI.LLM; +using Xunit; + +namespace TelegramSearchBot.LLM.Test.Service.AI.LLM { + public class LlmProviderCatalogTests { + [Theory] + [InlineData("claude-sonnet-5", "anthropic")] + [InlineData("Claude-Opus-5", "anthropic")] + [InlineData("gpt-5.6-luna", "responses")] + [InlineData("grok-4.6", "responses")] + [InlineData("gemini-3.8-flash", "google")] + public void OpenCodeZen_RulesMapModelsToProtocols(string modelName, string expectedBindingId) { + var zen = LlmProviderCatalog.FindById("opencode-zen")!; + + Assert.Equal(expectedBindingId, zen.ModelBindingRules.ResolveBindingId(modelName)); + } + + [Theory] + [InlineData("glm-5.3", null)] + [InlineData("kimi-k3", null)] + [InlineData("deepseek-v4-pro", null)] + public void OpenCodeZen_UnmatchedModelsKeepDefaultBinding(string modelName, string? expectedBindingId) { + var zen = LlmProviderCatalog.FindById("opencode-zen")!; + + Assert.Equal(expectedBindingId, zen.ModelBindingRules.ResolveBindingId(modelName)); + } + + [Theory] + [InlineData("grok-4.6", "responses")] + [InlineData("gpt-5.6-luna", "responses")] + [InlineData("glm-5.3", null)] + public void OpenCodeGo_RulesMapResponsesModelsOnly(string modelName, string? expectedBindingId) { + var go = LlmProviderCatalog.FindById("opencode-go")!; + + Assert.Equal(expectedBindingId, go.ModelBindingRules.ResolveBindingId(modelName)); + } + + [Fact] + public void OpenCodeGo_SeedsOfficialModels_WithResponsesBindingForGrokAndGpt() { + var go = LlmProviderCatalog.FindById("opencode-go")!; + + Assert.Contains("grok-4.6", go.DefaultModels); + Assert.Contains("minimax-m3", go.DefaultModels); + Assert.Contains("qwen3.7-max", go.DefaultModels); + Assert.Equal("responses", go.ModelBindingRules.ResolveBindingId("grok-4.6")); + Assert.Equal("responses", go.ModelBindingRules.ResolveBindingId("gpt-5.6-luna")); + Assert.Null(go.ModelBindingRules.ResolveBindingId("glm-5.3")); + Assert.True(go.CatalogIsEntitlement); + } + + [Fact] + public void OpenCodeZen_IsNotEntitlement_AndSeedsNoModels() { + var zen = LlmProviderCatalog.FindById("opencode-zen")!; + + Assert.False(zen.CatalogIsEntitlement); + Assert.Empty(zen.DefaultModels); + Assert.Equal("https://opencode.ai/zen/v1", zen.DefaultGateway); + Assert.Equal(3, zen.Bindings!.Count); + } + + [Theory] + [InlineData("https://opencode.ai/zen/go/v1", "opencode-go")] + [InlineData("https://opencode.ai/zen/go", "opencode-go")] + [InlineData("https://opencode.ai/zen/v1", "opencode-zen")] + [InlineData("https://opencode.ai/zen", "opencode-zen")] + [InlineData("https://api.openai.com/v1", null)] + [InlineData("not a url", null)] + public void FindForEndpoint_ClassifiesOpenCodeGateways(string endpoint, string? expectedPresetId) { + Assert.Equal(expectedPresetId, LlmProviderCatalog.FindForEndpoint(endpoint)?.Id); + } + + [Theory] + [InlineData("https://opencode.ai/zen", "/v1", "https://opencode.ai/zen/v1")] + [InlineData("https://opencode.ai/zen/v1", "/v1", "https://opencode.ai/zen/v1")] + [InlineData("https://opencode.ai/zen/v1/", "/v1", "https://opencode.ai/zen/v1")] + [InlineData("https://example.com", null, "https://example.com")] + public void BuildBindingEndpoint_AppendsSuffixAtMostOnce(string gateway, string? suffix, string expected) { + Assert.Equal(expected, LlmProviderCatalog.BuildBindingEndpoint(gateway, suffix)); + } + + [Fact] + public void EveryPreset_HasResolvableRuleBindings() { + foreach (var preset in LlmProviderCatalog.Presets) { + if (preset.ModelBindingRules == null) { + continue; + } + + var bindingIds = preset.Bindings?.Select(b => b.Id).ToHashSet(StringComparer.OrdinalIgnoreCase) + ?? new System.Collections.Generic.HashSet(); + foreach (var rule in preset.ModelBindingRules) { + // 规则引用的 binding 必须存在于预设声明中(默认 binding 由渠道提供,不在此列)。 + Assert.Contains(rule.BindingId, bindingIds); + } + } + } + } +} diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs index cbb68e6f..582895de 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs @@ -13,7 +13,44 @@ public sealed record LlmProviderPreset( string? DefaultGateway, string[] DefaultModels, bool RequiresApiKey, - string? Notes = null); + string? Notes = null, + /// Extra protocol bindings created alongside the channel default (multi-protocol gateways). + IReadOnlyList? Bindings = null, + /// Model-name prefix → binding id; unmatched models stay on the channel default binding. + IReadOnlyList? ModelBindingRules = null, + /// + /// True when the gateway catalog IS the entitlement (subscription, e.g. OpenCode Go): refresh may add + /// discovered models. False for pay-per-token catalogs (e.g. OpenCode Zen) where listing ≠ authorization. + /// + bool CatalogIsEntitlement = false); + + /// Extra API binding a preset creates: same gateway, different wire protocol/auth. + public sealed record LlmPresetBinding( + string Id, + LlmProtocol Protocol, + LlmAuthProfile AuthProfile, + /// Path appended to the entered gateway; not duplicated when the gateway already ends with it. + string EndpointSuffix = "/v1"); + + /// Model-name (case-insensitive) prefix → binding id. + public sealed record LlmModelBindingRule(string BindingId, params string[] Prefixes); + + public static class LlmModelBindingRules { + /// First matching rule wins; null = keep the channel default binding. + public static string? ResolveBindingId(this IReadOnlyList? rules, string? modelName) { + if (rules == null || string.IsNullOrWhiteSpace(modelName)) { + return null; + } + + foreach (var rule in rules) { + if (rule.Prefixes.Any(prefix => modelName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) { + return rule.BindingId; + } + } + + return null; + } + } /// /// Code-defined provider catalog (pi-style): common providers ship preconfigured so the bot @@ -69,23 +106,84 @@ public static class LlmProviderCatalog { "https://api.moonshot.cn/v1", new[] { "kimi-k2-0711-preview", "moonshot-v1-128k" }, RequiresApiKey: true), + // OpenCode 网关不是单协议服务商:Zen 按模型分属 Anthropic/OpenAI/Google 协议, + // Go 只有 Responses + Chat Completions。预设建渠道时按 ModelBindingRules 拆 binding。 new LlmProviderPreset( - "opencode-zen", "OpenCode Zen (官方订阅目录)", LLMProvider.Anthropic, - "https://opencode.ai/zen", + "opencode-zen", "OpenCode Zen (官方订阅目录)", LLMProvider.OpenAI, + "https://opencode.ai/zen/v1", Array.Empty(), RequiresApiKey: true, - Notes: "Anthropic 兼容;订阅 token 作为 API Key;目录模型不自动创建,授权模型请用 添加模型 手工维护。"), + Notes: "多协议网关(claude-*→Anthropic、gpt-*/grok-*→Responses、gemini-*→Google、其余→Chat Completions);按量计费,目录不自动创建,授权模型请用 `添加模型`。", + Bindings: new[] { + new LlmPresetBinding("anthropic", LlmProtocol.AnthropicMessages, LlmAuthProfile.AnthropicApiKey), + new LlmPresetBinding("responses", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer), + new LlmPresetBinding("google", LlmProtocol.Gemini, LlmAuthProfile.Bearer) + }, + ModelBindingRules: new[] { + new LlmModelBindingRule("anthropic", "claude-"), + new LlmModelBindingRule("responses", "gpt-", "grok-", "o3", "o4"), + new LlmModelBindingRule("google", "gemini-") + }), new LlmProviderPreset( - "opencode-go", "OpenCode Go (自建网关)", LLMProvider.Anthropic, - null, - Array.Empty(), + "opencode-go", "OpenCode Go (订阅网关)", LLMProvider.OpenAI, + "https://opencode.ai/zen/go/v1", + new[] { + "grok-4.6", "gpt-5.6-luna", + "glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", + "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", + "longcat-2.0", + "deepseek-v4.1-flash", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", + "minimax-m3", "minimax-m2.7", + "mimo-v2.5", "mimo-v2.5-pro", + "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", + "muse-spark-1.3-contributor", "muse-spark-1.2-contributor", + "hy4-preview", "hy3" + }, RequiresApiKey: true, - Notes: "创建时需输入你的 OpenCode Go 网关地址;Anthropic 兼容,自动携带 x-opencode-session 头;模型请用 添加模型 维护。"), + Notes: "OpenAI 兼容订阅网关(/responses + /chat/completions),自动携带 x-opencode-session;订阅覆盖目录模型,刷新会同步目录。自建网关请用 `新建渠道`。", + Bindings: new[] { + new LlmPresetBinding("responses", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer) + }, + ModelBindingRules: new[] { + new LlmModelBindingRule("responses", "grok-", "gpt-") + }, + CatalogIsEntitlement: true), }; public static LlmProviderPreset? FindById(string id) => Presets.FirstOrDefault(p => p.Id.Equals(id, StringComparison.OrdinalIgnoreCase)); + /// + /// 按渠道端点识别 OpenCode 预设(opencode.ai/zen/go/* → Go,其余 /zen/* → Zen)。 + /// 用于刷新时决定目录策略与模型→协议规则。 + /// + public static LlmProviderPreset? FindForEndpoint(string? endpoint) { + if (string.IsNullOrWhiteSpace(endpoint) || + !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) || + !string.Equals(uri.Host, "opencode.ai", StringComparison.OrdinalIgnoreCase)) { + return null; + } + + var path = uri.AbsolutePath; + if (path.StartsWith("/zen/go", StringComparison.OrdinalIgnoreCase)) { + return FindById("opencode-go"); + } + if (path.Equals("/zen", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("/zen/", StringComparison.OrdinalIgnoreCase)) { + return FindById("opencode-zen"); + } + return null; + } + + /// 把用户输入的网关补成带协议路径后缀的 binding 端点(后缀已存在时不重复追加)。 + public static string BuildBindingEndpoint(string? gateway, string? suffix) { + var trimmed = (gateway ?? string.Empty).TrimEnd('/'); + if (string.IsNullOrEmpty(suffix)) { + return trimmed; + } + return trimmed.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) ? trimmed : trimmed + suffix; + } + /// Human-readable numbered list for bot selection. public static string FormatMenu() { var sb = new System.Text.StringBuilder("请选择要创建的渠道预设:\n"); @@ -99,6 +197,7 @@ public static string FormatMenu() { sb.AppendLine($" 备注: {p.Notes}"); } } + sb.Append("\n发送编号选择;发送 取消 退出。"); return sb.ToString(); } diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderRegistry.cs b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderRegistry.cs index f7614b10..64afb254 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderRegistry.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderRegistry.cs @@ -91,7 +91,7 @@ public virtual async Task GetTransport(LLMChannel channel, L promptCachingEnabled, supportsVision, logger, httpClientFactory); case LlmProtocol.AnthropicMessages: return Transports.AnthropicMessagesTransport.Create(channel, binding, modelName, systemPrompt, - nativeTools, promptCachingEnabled, logger); + nativeTools, promptCachingEnabled, logger, chatId); case LlmProtocol.Gemini: return Transports.GeminiTransport.Create(channel, binding, modelName, supportsVision, logger, httpClientFactory); case LlmProtocol.Ollama: diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/Transports/AnthropicMessagesTransport.cs b/TelegramSearchBot.LLM/Service/AI/LLM/Transports/AnthropicMessagesTransport.cs index b08092fe..37e5b59a 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/Transports/AnthropicMessagesTransport.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/Transports/AnthropicMessagesTransport.cs @@ -25,7 +25,7 @@ public sealed class AnthropicMessagesTransport : ILlmTransport { /// construction glue formerly in AnthropicModelApi.CreateClient. /// public static LlmTransportBundle Create(LLMChannel channel, LLMApiBinding binding, string modelName, string systemPrompt, - bool nativeTools, bool promptCachingEnabled, ILogger logger) { + bool nativeTools, bool promptCachingEnabled, ILogger logger, long chatId = 0) { var apiKey = LlmBindingSupport.ResolveApiKey(channel, binding); var options = new Anthropic.Core.ClientOptions { ApiKey = apiKey, @@ -40,6 +40,11 @@ public static LlmTransportBundle Create(LLMChannel channel, LLMApiBinding bindin } options.BaseUrl = trimmed; } + + // OpenCode Go 要求每个请求携带稳定的 x-opencode-session(否则 400 MissingSessionID); + // 非 opencode 端点时 Apply 自会跳过,其它渠道无感。 + options.HttpClient = CreateSessionHttpClient(endpoint, chatId); + var transport = new AnthropicMessagesTransport(logger, new AnthropicClient(options), systemPrompt, modelName, channel, nativeTools, promptCachingEnabled); var config = new LlmTransportConfig { @@ -54,6 +59,16 @@ public static LlmTransportBundle Create(LLMChannel channel, LLMApiBinding bindin return new LlmTransportBundle(transport, config); } + /// + /// 构造带 OpenCode 会话头的 HttpClient(opencode 端点生效,其它端点原样)。 + /// 单独抽出以便测试验证 Go 的 x-opencode-session 行为。 + /// + internal static HttpClient CreateSessionHttpClient(string? endpoint, long chatId = 0) { + var httpClient = new HttpClient(); + OpencodeSessionHeaders.Apply(httpClient, endpoint, chatId > 0 ? $"tsb-{chatId}" : OpencodeSessionHeaders.GlobalSessionId); + return httpClient; + } + private readonly ILogger _logger; private readonly AnthropicClient _client; private readonly string _systemPrompt; diff --git a/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs b/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs index aa570496..e02de206 100644 --- a/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs +++ b/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs @@ -508,5 +508,50 @@ public async Task ExecuteAsync_SetMaxImageRetryCount() { Assert.NotNull(config); Assert.Equal("500", config.Value); } + + [Fact] + public async Task ExecuteAsync_PresetOpenCodeGo_CreatesResponseBindingAndAssignsModels() { + // Arrange + long chatId = 123; + var stateKey = $"llmconf:{chatId}:state"; + var dataKey = $"llmconf:{chatId}:data"; + var goIndex = LlmProviderCatalog.Presets.ToList().FindIndex(p => p.Id == "opencode-go") + 1; + + _dbMock.SetupSequence(d => d.StringGetAsync(stateKey, It.IsAny())) + .ReturnsAsync(RedisValue.Null) + .ReturnsAsync("awaiting_preset_selection") + .ReturnsAsync("awaiting_preset_apikey"); + _dbMock.SetupSequence(d => d.StringGetAsync(dataKey, It.IsAny())) + .ReturnsAsync("opencode-go"); + + helperMock.Setup(h => h.AddChannel(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(7); + helperMock.Setup(h => h.AddModelWithChannel(It.IsAny(), It.IsAny>())) + .ReturnsAsync(true); + helperMock.Setup(h => h.EnsureBinding(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(100); + helperMock.Setup(h => h.AssignModelBinding(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // Act + var result1 = await _service.ExecuteAsync("预制渠道", chatId); + Assert.True(result1.Item1); + Assert.Contains("OpenCode Go", result1.Item2); + + var result2 = await _service.ExecuteAsync(goIndex.ToString(), chatId); + Assert.True(result2.Item1); + Assert.Contains("API Key", result2.Item2); + + var result3 = await _service.ExecuteAsync("go-token", chatId); + + // Assert + Assert.True(result3.Item1); + Assert.Contains("渠道创建成功", result3.Item2); + helperMock.Verify(h => h.EnsureBinding(7, "https://opencode.ai/zen/go/v1", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer), Times.Once); + helperMock.Verify(h => h.AssignModelBinding(7, "grok-4.6", 100), Times.Once); + helperMock.Verify(h => h.AssignModelBinding(7, "gpt-5.6-luna", 100), Times.Once); + helperMock.Verify(h => h.AssignModelBinding(7, "glm-5.3", It.IsAny()), Times.Never); + helperMock.Verify(h => h.AssignModelBinding(7, It.IsAny(), It.IsAny()), Times.Exactly(2)); + } } } diff --git a/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs b/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs index bd35d4cd..528e5545 100644 --- a/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs +++ b/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs @@ -28,5 +28,14 @@ public interface IEditLLMConfHelper { /// 已有 preferred 时降级并告警(遵循 phase-2 resolver 的告警+稳定解析约定)。 /// Task SetModelPreferred(int channelId, string modelName, int bindingId); + + /// + /// 确保渠道下存在指定 endpoint+protocol+auth 的 binding(非默认,幂等),返回 binding Id。 + /// 多协议网关(如 OpenCode Zen/Go)在创建与刷新时按规则补建。 + /// + Task EnsureBinding(int channelId, string endpoint, LlmProtocol protocol, LlmAuthProfile authProfile); + + /// 把模型行改挂到指定 binding,并标记为模型级协议覆盖;成功返回 true。 + Task AssignModelBinding(int channelId, string modelName, int bindingId); } } diff --git a/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs b/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs index 43cc2f90..9604212e 100644 --- a/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs +++ b/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs @@ -114,10 +114,17 @@ public async Task RefreshAllChannel() { // 管理修复:每渠道恰好一个默认 binding(blueprint §六.8) var defaultBinding = await EnsureDefaultBinding(channel); - // Catalog ≠ Entitlement(blueprint §四.1/.5):OpenCode /models 不是授权快照, - // 刷新不得创建、不得软删任何模型行;能力 metadata 仍可安全 merge(不会创建/复活行)。 + // Catalog ≠ Entitlement(blueprint §四.1/.5):按量计费的 OpenCode 目录不是授权快照, + // 刷新不得创建、不得软删;订阅制网关(CatalogIsEntitlement,如 OpenCode Go)目录即授权, + // 做只增不删的同步,并按预设规则把模型挂到正确协议 binding 上。 if (IsOpenCodeBinding(defaultBinding)) { - _logger.LogInformation("通道 {ChannelName} 默认 binding 为 OpenCode 目录(opencode.ai/zen/*),跳过目录创建/软删", channel.Name); + var openCodePreset = LlmProviderCatalog.FindForEndpoint(defaultBinding?.Endpoint); + if (openCodePreset?.CatalogIsEntitlement == true) { + await RefreshOpenCodeEntitlementCatalogAsync(channel, defaultBinding, openCodePreset); + } else { + _logger.LogInformation("通道 {ChannelName} 默认 binding 为 OpenCode 目录(opencode.ai/zen/*),跳过目录创建/软删", channel.Name); + } + await TryUpdateCapabilitiesAsync(channel.Id); continue; } @@ -209,6 +216,74 @@ private async Task TryUpdateCapabilitiesAsync(int channelId) { } } + /// + /// 订阅制 OpenCode 网关(目录即授权,如 Go)的只增不删同步:拉取 /models,按预设规则把 + /// 新模型挂到正确协议 binding;既有行不动(手工调整优先),任何失败都不删除任何行。 + /// + private async Task RefreshOpenCodeEntitlementCatalogAsync(LLMChannel channel, LLMApiBinding? defaultBinding, LlmProviderPreset preset) { + try { + if (_LLMFactory.GetCatalog(LLMProvider.OpenAI) is not OpenAiModelApi catalog) { + _logger.LogWarning("通道 {ChannelName} 缺少 OpenAI 兼容目录实现,跳过订阅目录同步", channel.Name); + return; + } + + var models = (await catalog.GetAllModels(channel, defaultBinding)).ToList(); + if (models.Count == 0) { + _logger.LogWarning("通道 {ChannelName} 订阅目录为空,保留现有模型行", channel.Name); + return; + } + + var existing = await DataContext.ChannelsWithModel + .Where(x => x.LLMChannelId == channel.Id) + .ToListAsync(); + + var added = 0; + foreach (var modelName in models.Distinct(StringComparer.OrdinalIgnoreCase)) { + var row = existing.FirstOrDefault(r => r.ModelName.Equals(modelName, StringComparison.OrdinalIgnoreCase)); + if (row != null) { + if (row.IsDeleted) { + row.IsDeleted = false; + added++; + } + continue; + } + + var bindingId = await ResolvePresetBindingAsync(channel.Id, channel.Gateway, preset, modelName) ?? defaultBinding?.Id; + DataContext.ChannelsWithModel.Add(new ChannelWithModel { + LLMChannelId = channel.Id, + ModelName = modelName, + IsDeleted = false, + AuthorizationSource = AuthorizationSource.Discovered, + ApiBindingId = bindingId, + IsPreferred = defaultBinding?.Id != null && bindingId != defaultBinding.Id + }); + added++; + } + + if (added > 0) { + await DataContext.SaveChangesAsync(); + } + _logger.LogInformation("通道 {ChannelName} 订阅目录同步完成,新增/恢复 {Count} 个模型", channel.Name, added); + } catch (Exception ex) { + _logger.LogError(ex, "同步通道 {ChannelName} 的订阅目录失败,保留现有模型行", channel.Name); + } + } + + /// 按预设的模型前缀规则解析并补建 binding;未命中规则返回 null(用渠道默认 binding)。 + private async Task ResolvePresetBindingAsync(int channelId, string? gateway, LlmProviderPreset preset, string modelName) { + var ruleBindingId = preset.ModelBindingRules.ResolveBindingId(modelName); + var definition = ruleBindingId == null + ? null + : preset.Bindings?.FirstOrDefault(b => b.Id.Equals(ruleBindingId, StringComparison.OrdinalIgnoreCase)); + if (definition == null) { + return null; + } + + var endpoint = LlmProviderCatalog.BuildBindingEndpoint(gateway, definition.EndpointSuffix); + var bindingId = await EnsureBinding(channelId, endpoint, definition.Protocol, definition.AuthProfile); + return bindingId > 0 ? bindingId : null; + } + /// /// 获取所有LLM通道列表 /// @@ -627,6 +702,65 @@ public async Task SetModelPreferred(int channelId, string modelName, int b } } + /// + /// 确保渠道下存在指定 endpoint+protocol+auth 的 binding(非默认,幂等),返回 binding Id。 + /// 多协议网关(OpenCode Zen/Go)在创建与刷新时按规则补建。 + /// + public async Task EnsureBinding(int channelId, string endpoint, LlmProtocol protocol, LlmAuthProfile authProfile) { + try { + var normalized = (endpoint ?? string.Empty).TrimEnd('/'); + var candidates = await DataContext.LLMApiBindings + .Where(b => b.LLMChannelId == channelId && b.Protocol == protocol && b.AuthProfile == authProfile) + .ToListAsync(); + var existing = candidates.FirstOrDefault(b => + string.Equals((b.Endpoint ?? string.Empty).TrimEnd('/'), normalized, StringComparison.OrdinalIgnoreCase)); + if (existing != null) { + return existing.Id; + } + + var binding = new LLMApiBinding { + LLMChannelId = channelId, + Endpoint = normalized, + Protocol = protocol, + AuthProfile = authProfile, + IsDefault = false + }; + DataContext.LLMApiBindings.Add(binding); + await DataContext.SaveChangesAsync(); + _logger.LogInformation("为渠道 {ChannelId} 补建协议 binding({Protocol}/{AuthProfile} → {Endpoint})", channelId, protocol, authProfile, normalized); + return binding.Id; + } catch (Exception ex) { + _logger.LogError(ex, "为渠道 {ChannelId} 补建协议 binding 失败", channelId); + return -1; + } + } + + /// 把模型行改挂到指定 binding 并标记模型级协议覆盖(多协议网关的创建/刷新共用)。 + public async Task AssignModelBinding(int channelId, string modelName, int bindingId) { + if (string.IsNullOrWhiteSpace(modelName) || bindingId <= 0) { + return false; + } + + try { + var rows = await DataContext.ChannelsWithModel + .Where(m => m.LLMChannelId == channelId) + .ToListAsync(); + var row = rows.FirstOrDefault(r => r.ModelName.Equals(modelName, StringComparison.OrdinalIgnoreCase)); + if (row == null) { + _logger.LogWarning("挂载失败:渠道 {ChannelId} 没有模型 {ModelName} 的行", channelId, modelName); + return false; + } + + row.ApiBindingId = bindingId; + row.IsPreferred = true; + await DataContext.SaveChangesAsync(); + return true; + } catch (Exception ex) { + _logger.LogError(ex, "把模型 {ModelName} 挂到 binding {BindingId} 失败", modelName, bindingId); + return false; + } + } + /// /// channel Gateway/Provider 变更时同步默认 binding(镜像规则,blueprint §七)。 /// 无默认 binding 时不创建(补建由 EnsureDefaultBinding 在 AddChannel/RefreshAllChannel 负责)。 diff --git a/TelegramSearchBot/Service/Manage/EditLLMConfService.cs b/TelegramSearchBot/Service/Manage/EditLLMConfService.cs index 16af20cb..2a268d48 100644 --- a/TelegramSearchBot/Service/Manage/EditLLMConfService.cs +++ b/TelegramSearchBot/Service/Manage/EditLLMConfService.cs @@ -172,6 +172,34 @@ MusicGenerationToolSettingsService musicGenerationToolSettingsService message += $"\n已预置模型: {string.Join(", ", preset.DefaultModels)}"; message += "\n如需自定义模型,可使用 `添加模型`。"; } + + // 多协议网关(OpenCode Zen/Go):按预设声明补建 binding,并按模型名规则把预置模型挂到正确协议上。 + if (preset.Bindings is { Count: > 0 }) { + var bindingIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var binding in preset.Bindings) { + var endpoint = LlmProviderCatalog.BuildBindingEndpoint(gateway, binding.EndpointSuffix); + var bindingId = await Helper.EnsureBinding(channelId, endpoint, binding.Protocol, binding.AuthProfile); + if (bindingId > 0) { + bindingIds[binding.Id] = bindingId; + } + } + + var assigned = 0; + foreach (var model in preset.DefaultModels) { + var ruleId = preset.ModelBindingRules.ResolveBindingId(model); + if (ruleId != null && bindingIds.TryGetValue(ruleId, out var bindingId) && + await Helper.AssignModelBinding(channelId, model, bindingId)) { + assigned++; + } + } + + message += $"\n已配置 {bindingIds.Count} 条协议绑定"; + if (assigned > 0) { + message += $",按模型协议挂载 {assigned} 个模型"; + } + message += "。"; + } + return (true, message); } From 6d3e0b867696e17010ee894727d0a81c4307af72 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Thu, 17 Sep 2026 07:31:31 +0800 Subject: [PATCH 2/3] feat(llm): load provider presets from data (providers.json) Preset data lived in C# and only changed with a release; model seeds went stale and adding a provider meant a code change. - Providers/providers.json becomes the built-in catalog (embedded, with generatedAt); LlmProviderCatalogLoader overlays %LOCALAPPDATA%/TelegramSearchBot/providers.json when present, falling back to the built-in copy with a warning when the override is missing or invalid - LlmProviderCatalog.Presets/GeneratedAt/Source now read that document - default model lists refreshed against current vendor docs: Anthropic claude-opus-5/sonnet-5/haiku-4-5, OpenAI gpt-6-astra/5.6-sol/5.6-terra, Gemini 3.8-flash/3.1-pro-preview/3.5-flash, MiniMax M3/M2.7 (gateway api.minimaxi.com), DeepSeek deepseek-flash/v4-pro, Moonshot kimi-k3/k2.7-code - GeneralBootstrap logs catalog source/version at startup - Tests: LlmProviderCatalogLoaderTests (override wins, invalid override falls back with warning, empty list rejected, built-in keeps all 11 presets and the OpenCode bindings/rules) - Docs: user guide documents the override file and OpenCode protocol routing --- Docs/Bot_Commands_User_Guide.md | 2 + .../AI/LLM/LlmProviderCatalogLoaderTests.cs | 96 ++++++++++++++ .../Providers/providers.json | 125 ++++++++++++++++++ .../Service/AI/LLM/LlmProviderCatalog.cs | 110 +++------------ .../AI/LLM/LlmProviderCatalogLoader.cs | 81 ++++++++++++ .../TelegramSearchBot.LLM.csproj | 77 ++++++----- .../AppBootstrap/GeneralBootstrap.cs | 8 ++ 7 files changed, 369 insertions(+), 130 deletions(-) create mode 100644 TelegramSearchBot.LLM.Test/Service/AI/LLM/LlmProviderCatalogLoaderTests.cs create mode 100644 TelegramSearchBot.LLM/Providers/providers.json create mode 100644 TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalogLoader.cs diff --git a/Docs/Bot_Commands_User_Guide.md b/Docs/Bot_Commands_User_Guide.md index 64efde7d..12ff7d20 100644 --- a/Docs/Bot_Commands_User_Guide.md +++ b/Docs/Bot_Commands_User_Guide.md @@ -154,6 +154,8 @@ 4. 机器人回复: `请输入 <预设名> 的 API Key(本地/免 Key 服务发送 - 跳过):` 5. 管理员发送 API Key(或 `-`) 6. 机器人回复: `渠道创建成功(ID: x)`,并自动预置默认模型;自定义 endpoint / 模型仍可用 `新建渠道` / `添加模型` 修改。 + * **预设目录数据化**:预设来自内置 `providers.json`(每 6 小时的渠道刷新会让模型表跟随供应商最新状态);如需增改服务商,可仿照内置文件在 `%LOCALAPPDATA%/TelegramSearchBot/providers.json` 放置覆盖文件(含 `generatedAt` 与 `presets`),重启后优先生效,覆盖文件损坏会自动回退内置目录。 + * **多协议网关**:OpenCode Zen 按模型名自动分派协议(`claude-*`→Anthropic、`gpt-*`/`grok-*`→Responses、`gemini-*`→Gemini、其余→Chat Completions);OpenCode Go 为订阅网关,预置官方模型并通过 `/models` 只增不删地同步目录。 * **例如,使用 `新建渠道` 指令的交互流程可能如下:** 1. 管理员发送: `新建渠道` 2. 机器人回复: `请输入渠道的名称` diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/LlmProviderCatalogLoaderTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/LlmProviderCatalogLoaderTests.cs new file mode 100644 index 00000000..522b0b8c --- /dev/null +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/LlmProviderCatalogLoaderTests.cs @@ -0,0 +1,96 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using TelegramSearchBot.Model.AI; +using TelegramSearchBot.Service.AI.LLM; +using Xunit; + +namespace TelegramSearchBot.LLM.Test.Service.AI.LLM { + public class LlmProviderCatalogLoaderTests { + private static string TempPath(string name) { + var dir = Path.Combine(Path.GetTempPath(), "tsb-providers-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return Path.Combine(dir, name); + } + + [Fact] + public void BuiltInDocument_LoadsAllPresets_WhenOverrideMissing() { + var missing = TempPath("providers.json"); + + var doc = LlmProviderCatalogLoader.Load(overridePath: missing); + + Assert.Equal("builtin", doc.Source); + Assert.Contains("不存在", doc.Warning); + Assert.Equal(11, doc.Presets.Count); + Assert.NotNull(doc.GeneratedAt); + Assert.NotNull(doc.Presets.FirstOrDefault(p => p.Id == "opencode-go")); + } + + [Fact] + public void BuiltInDocument_KeepsOpenCodeBindingsAndRules() { + var go = LlmProviderCatalog.FindById("opencode-go")!; + + Assert.Single(go.Bindings!); + Assert.Equal(LlmProtocol.OpenAIResponses, go.Bindings![0].Protocol); + Assert.Equal("responses", go.ModelBindingRules.ResolveBindingId("grok-4.6")); + Assert.True(go.CatalogIsEntitlement); + Assert.Equal("https://opencode.ai/zen/go/v1", go.DefaultGateway); + } + + [Fact] + public void OverrideFile_WinsOverBuiltIn() { + var path = TempPath("providers.json"); + File.WriteAllText(path, """ + { + "generatedAt": "2030-01-01T00:00:00Z", + "presets": [ + { "id": "custom", "displayName": "Custom", "provider": "OpenAI", "defaultGateway": "https://example.com/v1", + "defaultModels": ["m1"], "requiresApiKey": true, + "bindings": [ { "id": "responses", "protocol": "OpenAIResponses", "authProfile": "Bearer" } ], + "modelBindingRules": [ { "bindingId": "responses", "prefixes": ["gpt-"] } ] } + ] + } + """); + + var doc = LlmProviderCatalogLoader.Load(overridePath: path); + + Assert.StartsWith("file:", doc.Source); + var preset = Assert.Single(doc.Presets); + Assert.Equal("custom", preset.Id); + Assert.Equal("responses", preset.ModelBindingRules.ResolveBindingId("gpt-x")); + Assert.Equal(LlmProtocol.OpenAIResponses, preset.Bindings![0].Protocol); + Assert.Equal(LlmAuthProfile.Bearer, preset.Bindings![0].AuthProfile); + } + + [Fact] + public void InvalidOverride_FallsBackToBuiltInStream_WithWarning() { + var path = TempPath("providers.json"); + File.WriteAllText(path, "{ this is not valid json"); + + using var builtIn = new MemoryStream(Encoding.UTF8.GetBytes(""" + { "generatedAt": "2026-01-01T00:00:00Z", "presets": [ { "id": "fallback", "displayName": "Fallback", "provider": "OpenAI", "defaultGateway": null, "defaultModels": [], "requiresApiKey": false } ] } + """)); + + var doc = LlmProviderCatalogLoader.Load(overridePath: path, builtIn: builtIn); + + Assert.Equal("builtin", doc.Source); + Assert.Contains("回退", doc.Warning); + Assert.Equal("fallback", Assert.Single(doc.Presets).Id); + } + + [Fact] + public void EmptyPresetList_InOverride_IsRejected() { + var path = TempPath("providers.json"); + File.WriteAllText(path, """{ "presets": [] }"""); + using var builtIn = new MemoryStream(Encoding.UTF8.GetBytes(""" + { "presets": [ { "id": "fallback", "displayName": "Fallback", "provider": "OpenAI", "defaultGateway": null, "defaultModels": [], "requiresApiKey": false } ] } + """)); + + var doc = LlmProviderCatalogLoader.Load(overridePath: path, builtIn: builtIn); + + Assert.Equal("fallback", Assert.Single(doc.Presets).Id); + Assert.NotNull(doc.Warning); + } + } +} diff --git a/TelegramSearchBot.LLM/Providers/providers.json b/TelegramSearchBot.LLM/Providers/providers.json new file mode 100644 index 00000000..b9114b4b --- /dev/null +++ b/TelegramSearchBot.LLM/Providers/providers.json @@ -0,0 +1,125 @@ +{ + "generatedAt": "2026-09-16T00:00:00Z", + "presets": [ + { + "id": "anthropic", + "displayName": "Anthropic 官方", + "provider": "Anthropic", + "defaultGateway": "https://api.anthropic.com", + "defaultModels": ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"], + "requiresApiKey": true + }, + { + "id": "openai", + "displayName": "OpenAI 官方 (Chat Completions)", + "provider": "OpenAI", + "defaultGateway": "https://api.openai.com/v1", + "defaultModels": ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra"], + "requiresApiKey": true + }, + { + "id": "openai-responses", + "displayName": "OpenAI 官方 (Responses API)", + "provider": "ResponsesAPI", + "defaultGateway": "https://api.openai.com/v1", + "defaultModels": ["gpt-6-astra", "gpt-5.6-sol"], + "requiresApiKey": true + }, + { + "id": "gemini", + "displayName": "Google Gemini", + "provider": "Gemini", + "defaultGateway": "https://generativelanguage.googleapis.com", + "defaultModels": ["gemini-3.8-flash", "gemini-3.1-pro-preview", "gemini-3.5-flash"], + "requiresApiKey": true + }, + { + "id": "minimax", + "displayName": "MiniMax", + "provider": "MiniMax", + "defaultGateway": "https://api.minimaxi.com/v1", + "defaultModels": ["MiniMax-M3", "MiniMax-M2.7"], + "requiresApiKey": true + }, + { + "id": "ollama", + "displayName": "本地 Ollama", + "provider": "Ollama", + "defaultGateway": "http://localhost:11434", + "defaultModels": [], + "requiresApiKey": false, + "notes": "本地服务无需 API Key;模型通过 `添加模型` 手动添加或自动发现。" + }, + { + "id": "lmstudio", + "displayName": "本地 LM Studio", + "provider": "LMStudio", + "defaultGateway": "http://localhost:1234/v1", + "defaultModels": [], + "requiresApiKey": false, + "notes": "本地服务无需 API Key;模型通过 `添加模型` 手动添加。" + }, + { + "id": "deepseek", + "displayName": "DeepSeek (OpenAI 兼容)", + "provider": "OpenAI", + "defaultGateway": "https://api.deepseek.com/v1", + "defaultModels": ["deepseek-flash", "deepseek-v4-pro"], + "requiresApiKey": true + }, + { + "id": "moonshot", + "displayName": "Moonshot Kimi (OpenAI 兼容)", + "provider": "OpenAI", + "defaultGateway": "https://api.moonshot.cn/v1", + "defaultModels": ["kimi-k3", "kimi-k2.7-code"], + "requiresApiKey": true + }, + { + "id": "opencode-zen", + "displayName": "OpenCode Zen (官方订阅目录)", + "provider": "OpenAI", + "defaultGateway": "https://opencode.ai/zen/v1", + "defaultModels": [], + "requiresApiKey": true, + "notes": "多协议网关(claude-*→Anthropic、gpt-*/grok-*→Responses、gemini-*→Google、其余→Chat Completions);按量计费,目录不自动创建,授权模型请用 `添加模型`。", + "bindings": [ + { "id": "anthropic", "protocol": "AnthropicMessages", "authProfile": "AnthropicApiKey", "endpointSuffix": "/v1" }, + { "id": "responses", "protocol": "OpenAIResponses", "authProfile": "Bearer", "endpointSuffix": "/v1" }, + { "id": "google", "protocol": "Gemini", "authProfile": "Bearer", "endpointSuffix": "/v1" } + ], + "modelBindingRules": [ + { "bindingId": "anthropic", "prefixes": ["claude-"] }, + { "bindingId": "responses", "prefixes": ["gpt-", "grok-", "o3", "o4"] }, + { "bindingId": "google", "prefixes": ["gemini-"] } + ] + }, + { + "id": "opencode-go", + "displayName": "OpenCode Go (订阅网关)", + "provider": "OpenAI", + "defaultGateway": "https://opencode.ai/zen/go/v1", + "defaultModels": [ + "grok-4.6", "gpt-5.6-luna", + "glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", + "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", + "longcat-2.0", + "deepseek-v4.1-flash", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", + "minimax-m3", "minimax-m2.7", + "mimo-v2.5", "mimo-v2.5-pro", + "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", + "muse-spark-1.3-contributor", "muse-spark-1.2-contributor", + "hy4-preview", "hy3" + ], + "requiresApiKey": true, + "notes": "OpenAI 兼容订阅网关(/responses + /chat/completions),自动携带 x-opencode-session;订阅覆盖目录模型,刷新会同步目录。自建网关请用 `新建渠道`。", + "bindings": [ + { "id": "responses", "protocol": "OpenAIResponses", "authProfile": "Bearer", "endpointSuffix": "/v1" } + ], + "modelBindingRules": [ + { "bindingId": "responses", "prefixes": ["grok-", "gpt-"] } + ], + "catalogIsEntitlement": true + } + ] +} diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs index 582895de..bbe0929a 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs @@ -4,7 +4,7 @@ using TelegramSearchBot.Model.AI; namespace TelegramSearchBot.Service.AI.LLM { - /// A built-in provider preset: one code-defined entry of the provider catalog. + /// A built-in provider preset: one entry of the provider catalog. public sealed record LlmProviderPreset( string Id, string DisplayName, @@ -53,102 +53,22 @@ public static class LlmModelBindingRules { } /// - /// Code-defined provider catalog (pi-style): common providers ship preconfigured so the bot - /// admin only picks one and enters an API key. Custom endpoints are still supported by the - /// existing manual channel flow (新建渠道) and LLMApiBinding overrides. + /// Provider catalog (pi-style): presets ship as data (`Providers/providers.json`, embedded) so providers and + /// model lists can be updated without code changes — a user override at + /// `%LOCALAPPDATA%/TelegramSearchBot/providers.json` wins over the built-in copy. + /// Custom endpoints are still supported by the manual channel flow (新建渠道) and LLMApiBinding overrides. /// public static class LlmProviderCatalog { - public static readonly IReadOnlyList Presets = new[] { - new LlmProviderPreset( - "anthropic", "Anthropic 官方", LLMProvider.Anthropic, - "https://api.anthropic.com", - new[] { "claude-sonnet-4-5", "claude-opus-4-1", "claude-haiku-4-5" }, - RequiresApiKey: true), - new LlmProviderPreset( - "openai", "OpenAI 官方 (Chat Completions)", LLMProvider.OpenAI, - "https://api.openai.com/v1", - new[] { "gpt-4o", "gpt-4o-mini", "gpt-4.1" }, - RequiresApiKey: true), - new LlmProviderPreset( - "openai-responses", "OpenAI 官方 (Responses API)", LLMProvider.ResponsesAPI, - "https://api.openai.com/v1", - new[] { "gpt-4o", "gpt-4.1" }, - RequiresApiKey: true), - new LlmProviderPreset( - "gemini", "Google Gemini", LLMProvider.Gemini, - "https://generativelanguage.googleapis.com", - new[] { "gemini-2.0-flash", "gemini-2.5-pro" }, - RequiresApiKey: true), - new LlmProviderPreset( - "minimax", "MiniMax", LLMProvider.MiniMax, - "https://api.minimax.chat/v1", - new[] { "MiniMax-Text-01", "abab6.5s-chat" }, - RequiresApiKey: true), - new LlmProviderPreset( - "ollama", "本地 Ollama", LLMProvider.Ollama, - "http://localhost:11434", - Array.Empty(), - RequiresApiKey: false, - Notes: "本地服务无需 API Key;模型通过 `添加模型` 手动添加或自动发现。"), - new LlmProviderPreset( - "lmstudio", "本地 LM Studio", LLMProvider.LMStudio, - "http://localhost:1234/v1", - Array.Empty(), - RequiresApiKey: false, - Notes: "本地服务无需 API Key;模型通过 `添加模型` 手动添加。"), - new LlmProviderPreset( - "deepseek", "DeepSeek (OpenAI 兼容)", LLMProvider.OpenAI, - "https://api.deepseek.com/v1", - new[] { "deepseek-chat", "deepseek-reasoner" }, - RequiresApiKey: true), - new LlmProviderPreset( - "moonshot", "Moonshot Kimi (OpenAI 兼容)", LLMProvider.OpenAI, - "https://api.moonshot.cn/v1", - new[] { "kimi-k2-0711-preview", "moonshot-v1-128k" }, - RequiresApiKey: true), - // OpenCode 网关不是单协议服务商:Zen 按模型分属 Anthropic/OpenAI/Google 协议, - // Go 只有 Responses + Chat Completions。预设建渠道时按 ModelBindingRules 拆 binding。 - new LlmProviderPreset( - "opencode-zen", "OpenCode Zen (官方订阅目录)", LLMProvider.OpenAI, - "https://opencode.ai/zen/v1", - Array.Empty(), - RequiresApiKey: true, - Notes: "多协议网关(claude-*→Anthropic、gpt-*/grok-*→Responses、gemini-*→Google、其余→Chat Completions);按量计费,目录不自动创建,授权模型请用 `添加模型`。", - Bindings: new[] { - new LlmPresetBinding("anthropic", LlmProtocol.AnthropicMessages, LlmAuthProfile.AnthropicApiKey), - new LlmPresetBinding("responses", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer), - new LlmPresetBinding("google", LlmProtocol.Gemini, LlmAuthProfile.Bearer) - }, - ModelBindingRules: new[] { - new LlmModelBindingRule("anthropic", "claude-"), - new LlmModelBindingRule("responses", "gpt-", "grok-", "o3", "o4"), - new LlmModelBindingRule("google", "gemini-") - }), - new LlmProviderPreset( - "opencode-go", "OpenCode Go (订阅网关)", LLMProvider.OpenAI, - "https://opencode.ai/zen/go/v1", - new[] { - "grok-4.6", "gpt-5.6-luna", - "glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", - "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", - "longcat-2.0", - "deepseek-v4.1-flash", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", - "minimax-m3", "minimax-m2.7", - "mimo-v2.5", "mimo-v2.5-pro", - "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", - "muse-spark-1.3-contributor", "muse-spark-1.2-contributor", - "hy4-preview", "hy3" - }, - RequiresApiKey: true, - Notes: "OpenAI 兼容订阅网关(/responses + /chat/completions),自动携带 x-opencode-session;订阅覆盖目录模型,刷新会同步目录。自建网关请用 `新建渠道`。", - Bindings: new[] { - new LlmPresetBinding("responses", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer) - }, - ModelBindingRules: new[] { - new LlmModelBindingRule("responses", "grok-", "gpt-") - }, - CatalogIsEntitlement: true), - }; + private static readonly Lazy Document = + new(() => LlmProviderCatalogLoader.Load(), LazyThreadSafetyMode.ExecutionAndPublication); + + public static IReadOnlyList Presets => Document.Value.Presets; + + /// 内置/覆盖目录的数据版本(providers.json 的 generatedAt)。 + public static DateTimeOffset? GeneratedAt => Document.Value.GeneratedAt; + + /// 目录来源(`builtin` 或 `file:`),便于排障。 + public static string Source => Document.Value.Source; public static LlmProviderPreset? FindById(string id) => Presets.FirstOrDefault(p => p.Id.Equals(id, StringComparison.OrdinalIgnoreCase)); diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalogLoader.cs b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalogLoader.cs new file mode 100644 index 00000000..1332d85e --- /dev/null +++ b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalogLoader.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using TelegramSearchBot.Common; + +namespace TelegramSearchBot.Service.AI.LLM { + /// 加载结果:预设列表 + 数据版本 + 来源(便于日志与测试)。 + public sealed record LlmProviderCatalogDocument( + DateTimeOffset? GeneratedAt, + IReadOnlyList Presets, + string Source, + string? Warning = null); + + /// + /// providers.json 加载器:优先用户覆盖 `%LOCALAPPDATA%/TelegramSearchBot/providers.json` + /// (不改代码、不发版即可增改服务商),其次程序集内置资源; + /// 覆盖文件损坏/为空时回退内置并在 Warning 中说明,绝不因目录问题启动失败。 + /// + public static class LlmProviderCatalogLoader { + public const string OverrideFileName = "providers.json"; + public const string BuiltInResourceName = "TelegramSearchBot.Service.AI.LLM.providers.json"; + + private static readonly JsonSerializerOptions SerializerOptions = new() { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + Converters = { new JsonStringEnumConverter() } + }; + + public static LlmProviderCatalogDocument Load(string? overridePath = null, Stream? builtIn = null) { + var path = overridePath ?? ResolveDefaultOverridePath(); + string? warning = null; + + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) { + try { + return Parse(File.ReadAllText(path), $"file:{path}"); + } catch (Exception ex) { + warning = $"providers.json 覆盖文件解析失败,回退内置目录: {ex.Message}"; + } + } else if (overridePath != null) { + warning = $"providers.json 覆盖文件不存在,使用内置目录: {overridePath}"; + } + + if (builtIn == null) { + using var stream = typeof(LlmProviderCatalogLoader).Assembly.GetManifestResourceStream(BuiltInResourceName) + ?? throw new InvalidOperationException($"内置预设目录资源缺失: {BuiltInResourceName}"); + using var reader = new StreamReader(stream); + return Parse(reader.ReadToEnd(), "builtin", warning); + } + + builtIn.Position = 0; + using (var reader = new StreamReader(builtIn, leaveOpen: true)) { + return Parse(reader.ReadToEnd(), "builtin", warning); + } + } + + /// Env.WorkDir 需要 Config.json,测试/无配置环境下退回空路径(只用内置目录)。 + private static string ResolveDefaultOverridePath() { + try { + return Path.Combine(Env.WorkDir, OverrideFileName); + } catch { + return string.Empty; + } + } + + private static LlmProviderCatalogDocument Parse(string json, string source, string? warning = null) { + var file = JsonSerializer.Deserialize(json, SerializerOptions) + ?? throw new InvalidOperationException("预设目录内容为空"); + if (file.Presets == null || file.Presets.Count == 0) { + throw new InvalidOperationException("预设目录里没有任何 preset"); + } + + return new LlmProviderCatalogDocument(file.GeneratedAt, file.Presets, source, warning); + } + + private sealed record CatalogFile(DateTimeOffset? GeneratedAt, List Presets); + } +} diff --git a/TelegramSearchBot.LLM/TelegramSearchBot.LLM.csproj b/TelegramSearchBot.LLM/TelegramSearchBot.LLM.csproj index 87206f1d..986e4bad 100644 --- a/TelegramSearchBot.LLM/TelegramSearchBot.LLM.csproj +++ b/TelegramSearchBot.LLM/TelegramSearchBot.LLM.csproj @@ -1,35 +1,42 @@ - - - - net10.0 - enable - warnings - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + net10.0 + enable + warnings + + + + + + + + + + + TelegramSearchBot.Service.AI.LLM.providers.json + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs index 3a769749..b7826fc9 100644 --- a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs +++ b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs @@ -261,6 +261,14 @@ public static async Task Startup(string[] args) { Log.Warning(ex, "Failed to export tool definitions to Redis. Agent processes may have limited tools."); } + // 预加载并记录预设目录来源/版本(providers.json 覆盖或内置) + try { + Log.Information("LLM 预设目录已加载: {Source} (generatedAt={GeneratedAt}, presets={Count})", + LlmProviderCatalog.Source, LlmProviderCatalog.GeneratedAt, LlmProviderCatalog.Presets.Count); + } catch (Exception ex) { + Log.Error(ex, "LLM 预设目录加载失败,预制渠道将不可用"); + } + // 启动Host,SchedulerService作为HostedService会自动启动 await host.StartAsync(); Log.Information("Host已启动,定时任务调度器已作为后台服务启动"); From 32a39efaf52a845e63117e3e86f50ad0e0c06eaf Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Thu, 17 Sep 2026 10:20:49 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(llm):=20binding=20management=20in=20?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E6=B8=A0=E9=81=93=20+=20binding-aware=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=A8=A1=E5=9E=8B=20(#396)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-binding channels (OpenCode Zen/Go and anything created from the new presets) had no admin surface: bindings could only be fixed by editing SQLite, and 移除模型 deleted the first row matching a model name, which could remove the wrong binding's row. - 编辑渠道 gains option 7 (协议绑定): lists the channel bindings with protocol/auth/default marker, promotes a chosen binding to default (PromoteBinding mirrors channel Gateway/Provider) or walks through endpoint -> protocol -> auth to add a new binding via EnsureBinding - 移除模型 now lists rows through GetModelRowsByChannelId (same 'model [channel/binding/protocol]' display as 查看模型) and deletes by row id (RemoveModelRow), so duplicate model names across bindings cannot collide - new LLMConf states + handlers; helper/interface additions GetBindings, PromoteBinding, GetModelRowsByChannelId, RemoveModelRow - tests: preset/binding promote + add flows (state machine), promote/mirror, row display + row-id delete, bindings ordering; user guide updated --- Docs/Bot_Commands_User_Guide.md | 9 +- .../Manage/EditLLMConfHelperTest.cs | 102 ++++++++++- .../Manage/EditLLMConfTest.cs | 134 ++++++++++---- .../Interface/Manage/IEditLLMConfHelper.cs | 15 ++ TelegramSearchBot/Model/AI/LLMConfState.cs | 12 ++ .../Service/Manage/EditLLMConfHelper.cs | 76 ++++++++ .../Service/Manage/EditLLMConfService.cs | 168 ++++++++++++++++-- 7 files changed, 456 insertions(+), 60 deletions(-) diff --git a/Docs/Bot_Commands_User_Guide.md b/Docs/Bot_Commands_User_Guide.md index 12ff7d20..0b9797c3 100644 --- a/Docs/Bot_Commands_User_Guide.md +++ b/Docs/Bot_Commands_User_Guide.md @@ -182,7 +182,7 @@ 1. 管理员发送: `编辑渠道` 2. 机器人回复: (列出现有渠道) `请选择要编辑的渠道ID:` 3. 管理员发送: (选择一个渠道ID,例如 `1`) - 4. 机器人回复: (列出可编辑字段) `请选择要编辑的字段:\n1. 名称 (当前值)\n2. 地址 (当前值)\n3. 类型 (当前值)\n4. API Key\n5. 最大并行数量 (当前值)\n6. 优先级 (当前值)` + 4. 机器人回复: (列出可编辑字段) `请选择要编辑的字段:\n1. 名称 (当前值)\n2. 地址 (当前值)\n3. 类型 (当前值)\n4. API Key\n5. 最大并行数量 (当前值)\n6. 优先级 (当前值)\n7. 协议绑定` 5. 管理员发送: (选择字段编号) 6. **如果选择 `1`, `2`, `4`, `5`, `6`**: - 机器人回复: `请输入新的值:` @@ -191,9 +191,14 @@ 6. **如果选择 `3` (类型)**: - 机器人回复: `请选择渠道类型:\n1. OpenAI\n2. Ollama` - 管理员发送: (输入选项编号,例如 `1`) + 6. **如果选择 `7` (协议绑定)**(多协议网关用,如 OpenCode Zen/Go): + - 机器人回复: (列出绑定) `渠道 x 的协议绑定:\n3. https://opencode.ai/zen/v1 (OpenAIChat/Bearer) [默认]\n...` + - 管理员发送: 绑定ID(设为默认)或 `0`(新增绑定) + - 新增时依次输入:端点地址 → 线协议(OpenAIChat/OpenAIResponses/AnthropicMessages/Ollama/Gemini)→ 认证方式(Bearer/AnthropicApiKey/None) + - 机器人回复: `绑定创建成功(ID: x,协议/认证)` 7. 管理员发送: (输入新值或选择编号) 8. 机器人回复: `更新成功` (或失败信息) - * **`移除模型` 指令的交互流程示例:** + * **`移除模型` 指令的交互流程示例:**(按模型行删除:多 binding 下同名模型会带 `[渠道/binding/协议]` 标注,删错行不会发生) 1. 管理员发送: `移除模型` 2. 机器人回复: (列出现有渠道) `请选择要移除模型的渠道ID:` 3. 管理员发送: (选择一个渠道ID,例如 `1`) diff --git a/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs b/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs index 705436b4..1a5f94c0 100644 --- a/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs +++ b/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs @@ -496,9 +496,8 @@ private async Task SeedChannelWithDefaultBinding(int id, string n [Fact] public async Task RefreshAllChannel_OpenCodeBinding_CreatesNoRowsAndSoftDeletesNothing() { - // Arrange: OpenCode 默认 binding(opencode.ai/zen/* 空间)——目录不是授权快照, - // 刷新不得创建行、不得软删/复活任何行(blueprint §四.1/.5) - const string openCodeEndpoint = "https://opencode.ai/zen/go/v1/chat/completions"; + // Arrange: OpenCode Zen 是按量计费目录(非授权快照),刷新不得创建行、不得软删/复活任何行(blueprint §四.1/.5) + const string openCodeEndpoint = "https://opencode.ai/zen/v1/chat/completions"; var binding = await SeedChannelWithDefaultBinding(20, "OpenCode", LLMProvider.OpenAI, gateway: openCodeEndpoint, endpoint: openCodeEndpoint); await _context.ChannelsWithModel.AddRangeAsync(new[] { @@ -738,6 +737,103 @@ public async Task SetModelPreferred_UnknownRowRejected() { Assert.False(ok); } + /// OpenCode Go 是订阅网关(目录即授权):只增不删;既有行/手工行不被覆盖或删除。 + [Fact] + public async Task RefreshAllChannel_OpenCodeGo_SyncsCatalogAddOnly() { + // Arrange + const string goEndpoint = "https://opencode.ai/zen/go/v1"; + var binding = await SeedChannelWithDefaultBinding(40, "OpenCode Go", LLMProvider.OpenAI, + gateway: goEndpoint, endpoint: goEndpoint); + await _context.ChannelsWithModel.AddRangeAsync(new[] { + new ChannelWithModel { LLMChannelId = 40, ModelName = "existing-manual", IsDeleted = false, AuthorizationSource = AuthorizationSource.Manual }, + new ChannelWithModel { LLMChannelId = 40, ModelName = "restored-discovered", IsDeleted = true, AuthorizationSource = AuthorizationSource.Discovered, ApiBindingId = binding.Id }, + new ChannelWithModel { LLMChannelId = 40, ModelName = "catalog-dropped", IsDeleted = false, AuthorizationSource = AuthorizationSource.Discovered, ApiBindingId = binding.Id } + }); + await _context.SaveChangesAsync(); + + _openAIServiceMock.Setup(o => o.GetAllModels(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { "new-model", "existing-manual", "restored-discovered" }); + + // Act + await _helper.RefreshAllChannel(); + + // Assert: 新模型以 Discovered 行加入;既有行不重复;软删行恢复;目录里没有的行不被软删 + var rows = await _context.ChannelsWithModel.Where(m => m.LLMChannelId == 40).ToListAsync(); + Assert.Single(rows.Where(r => r.ModelName == "new-model")); + Assert.Single(rows.Where(r => r.ModelName == "existing-manual")); + Assert.False(rows.Single(r => r.ModelName == "restored-discovered").IsDeleted); + Assert.False(rows.Single(r => r.ModelName == "catalog-dropped").IsDeleted); + } + + [Fact] + public async Task PromoteBinding_SwitchesDefaultAndMirrorsChannel() { + // Arrange + var defaultBinding = await SeedChannelWithDefaultBinding(41, "Multi Prot", LLMProvider.OpenAI, gateway: "http://a", endpoint: "http://a"); + var responsesBinding = new LLMApiBinding { + LLMChannelId = 41, + Endpoint = "http://b", + Protocol = LlmProtocol.OpenAIResponses, + AuthProfile = LlmAuthProfile.Bearer, + IsDefault = false + }; + await _context.LLMApiBindings.AddAsync(responsesBinding); + await _context.SaveChangesAsync(); + + // Act + var ok = await _helper.PromoteBinding(41, responsesBinding.Id); + + // Assert + Assert.True(ok); + Assert.True((await _context.LLMApiBindings.FindAsync(responsesBinding.Id))!.IsDefault); + Assert.False((await _context.LLMApiBindings.FindAsync(defaultBinding.Id))!.IsDefault); + var channel = await _context.LLMChannels.FindAsync(41); + Assert.Equal("http://b", channel.Gateway); + Assert.Equal(LLMProvider.ResponsesAPI, channel.Provider); + } + + [Fact] + public async Task GetModelRowsByChannelId_QualifiesDuplicateNamesAndRemoveModelRowKeepsTheOther() { + // Arrange + var bindingA = await SeedChannelWithDefaultBinding(42, "Multi Model", LLMProvider.OpenAI, gateway: "http://a", endpoint: "http://a"); + var bindingB = new LLMApiBinding { LLMChannelId = 42, Endpoint = "http://b", Protocol = LlmProtocol.OpenAIResponses, AuthProfile = LlmAuthProfile.Bearer, IsDefault = false }; + await _context.LLMApiBindings.AddAsync(bindingB); + await _context.SaveChangesAsync(); + + var rowA = new ChannelWithModel { LLMChannelId = 42, ModelName = "shared-model", ApiBindingId = bindingA.Id }; + var rowB = new ChannelWithModel { LLMChannelId = 42, ModelName = "shared-model", ApiBindingId = bindingB.Id }; + var rowC = new ChannelWithModel { LLMChannelId = 42, ModelName = "single-model", ApiBindingId = bindingA.Id }; + await _context.ChannelsWithModel.AddRangeAsync(rowA, rowB, rowC); + await _context.SaveChangesAsync(); + + // Act + var rows = await _helper.GetModelRowsByChannelId(42); + + // Assert: 重名行带协议标注,唯一行保持原名 + Assert.Equal(3, rows.Count); + Assert.Equal(2, rows.Count(r => r.Display.Contains("shared-model ["))); + Assert.Contains(rows, r => r.Display == "single-model"); + + // 按行 Id 删除只影响目标行 + var rowToRemove = rows.First(r => r.RowId == rowB.Id); + Assert.True(await _helper.RemoveModelRow(rowToRemove.RowId)); + var remaining = await _context.ChannelsWithModel.Where(m => m.LLMChannelId == 42).ToListAsync(); + Assert.Equal(2, remaining.Count); + Assert.DoesNotContain(remaining, r => r.Id == rowB.Id); + Assert.Contains(remaining, r => r.Id == rowA.Id); + } + + [Fact] + public async Task GetBindings_ReturnsChannelBindingsOrderedById() { + await SeedChannelWithDefaultBinding(43, "Bindings", LLMProvider.OpenAI, gateway: "http://a", endpoint: "http://a"); + await _context.LLMApiBindings.AddAsync(new LLMApiBinding { LLMChannelId = 43, Endpoint = "http://b", Protocol = LlmProtocol.OpenAIResponses, AuthProfile = LlmAuthProfile.Bearer }); + await _context.SaveChangesAsync(); + + var bindings = await _helper.GetBindings(43); + + Assert.Equal(2, bindings.Count); + Assert.True(bindings[0].Id < bindings[1].Id); + } + [Fact] public async Task UpdateChannel_GatewayAndProviderChange_SyncsDefaultBinding() { // Arrange diff --git a/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs b/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs index e02de206..f6c9efc2 100644 --- a/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs +++ b/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs @@ -255,61 +255,43 @@ public async Task ExecuteAsync_RemoveModel_CompleteFlow() { ApiKey = "test-key", Provider = LLMProvider.OpenAI }; - await _context.LLMChannels.AddAsync(channel); - await _context.ChannelsWithModel.AddAsync(new ChannelWithModel { - LLMChannelId = 1, - ModelName = "model1" - }); - // Add model2 to the in-memory database - await _context.ChannelsWithModel.AddAsync(new ChannelWithModel { - LLMChannelId = 1, - ModelName = "model2" - }); - await _context.SaveChangesAsync(); var stateKey = $"llmconf:{chatId}:state"; var dataKey = $"llmconf:{chatId}:data"; - // Setup state transitions + // 1. initial state, 2. state after "移除模型", 3. state after channel input, 4. row id payload _dbMock.SetupSequence(d => d.StringGetAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(RedisValue.Null) // 1. Initial state before "移除模型" - .ReturnsAsync("removing_model_select_channel") // 2. State after "移除模型" command - .ReturnsAsync("removing_model_select") // 3. State after channel ID input - .ReturnsAsync("1|model1,model2"); // 4. Data read by HandleRemovingModelSelectAsync - - // Setup helper mock to return channel - helperMock.Setup(h => h.GetChannelById(1)) - .ReturnsAsync(channel); - - // Setup helper mock to return a channel in GetAllChannels, which should trigger GetModelsByChannelId in service (but service doesn't call helper here) - // This setup is still needed for the initial "移除模型" command which lists channels. - helperMock.Setup(h => h.GetAllChannels()) - .ReturnsAsync(new List { channel }); + .ReturnsAsync(RedisValue.Null) + .ReturnsAsync("removing_model_select_channel") + .ReturnsAsync("removing_model_select") + .ReturnsAsync("1|101,102"); + + helperMock.Setup(h => h.GetChannelById(1)).ReturnsAsync(channel); + helperMock.Setup(h => h.GetAllChannels()).ReturnsAsync(new List { channel }); + helperMock.Setup(h => h.GetModelRowsByChannelId(1)).ReturnsAsync(new List<(long, string, string)> { + (101, "model1", "model1"), + (102, "model2", "model2") + }); + helperMock.Setup(h => h.RemoveModelRow(It.IsAny())).ReturnsAsync(true); // Act & Assert var result1 = await _service.ExecuteAsync("移除模型", chatId); Assert.True(result1.Item1); Assert.Contains("请选择要移除模型的渠道ID:", result1.Item2); - // Verify the in-memory database contains the expected models - var modelsInDb = await _context.ChannelsWithModel.Where(m => m.LLMChannelId == 1).ToListAsync(); - Assert.NotNull(modelsInDb); - Assert.Equal(2, modelsInDb.Count); - Assert.Contains(modelsInDb, m => m.ModelName == "model1"); - Assert.Contains(modelsInDb, m => m.ModelName == "model2"); - - var result2 = await _service.ExecuteAsync("1", chatId); // User inputs channel ID + var result2 = await _service.ExecuteAsync("1", chatId); Assert.True(result2.Item1); - // Verify the response lists models Assert.Contains("请选择要移除的模型:", result2.Item2); Assert.Contains("1. model1", result2.Item2); + Assert.Contains("2. model2", result2.Item2); - var result3 = await _service.ExecuteAsync("1", chatId); // User inputs model index (1 for model1) + var result3 = await _service.ExecuteAsync("1", chatId); // 选择第一行 Assert.True(result3.Item1); Assert.Equal("模型移除成功", result3.Item2); - // Verify model was removed by helper (as service calls helper) - helperMock.Verify(h => h.RemoveModelFromChannel(1, "model1"), Times.Once); + // 按行 Id 删除(不是按模型名,多 binding 下不会删错行) + helperMock.Verify(h => h.RemoveModelRow(101), Times.Once); + helperMock.Verify(h => h.RemoveModelRow(It.IsAny()), Times.Once); // Verify Redis keys are deleted _dbMock.Verify(d => d.KeyDeleteAsync(stateKey, It.IsAny()), Times.Once); @@ -553,5 +535,83 @@ public async Task ExecuteAsync_PresetOpenCodeGo_CreatesResponseBindingAndAssigns helperMock.Verify(h => h.AssignModelBinding(7, "glm-5.3", It.IsAny()), Times.Never); helperMock.Verify(h => h.AssignModelBinding(7, It.IsAny(), It.IsAny()), Times.Exactly(2)); } + + [Fact] + public async Task ExecuteAsync_EditChannelBindings_PromotesSelectedBinding() { + // Arrange + long chatId = 123; + var channel = new LLMChannel { Id = 1, Name = "Test Channel", Provider = LLMProvider.OpenAI, Gateway = "http://test.com" }; + var binding = new LLMApiBinding { Id = 5, LLMChannelId = 1, Endpoint = "http://test.com/v1", Protocol = LlmProtocol.OpenAIResponses, AuthProfile = LlmAuthProfile.Bearer }; + + _dbMock.SetupSequence(d => d.StringGetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(RedisValue.Null) + .ReturnsAsync("editing_select_channel") + .ReturnsAsync("editing_select_field") + .ReturnsAsync("1") + .ReturnsAsync("editing_select_binding") + .ReturnsAsync("1|7"); + + helperMock.Setup(h => h.GetAllChannels()).ReturnsAsync(new List { channel }); + helperMock.Setup(h => h.GetChannelById(1)).ReturnsAsync(channel); + helperMock.Setup(h => h.GetBindings(1)).ReturnsAsync(new List { binding }); + helperMock.Setup(h => h.PromoteBinding(1, 5)).ReturnsAsync(true); + + // Act + var result1 = await _service.ExecuteAsync("编辑渠道", chatId); + Assert.True(result1.Item1); + + var result2 = await _service.ExecuteAsync("1", chatId); + Assert.Contains("7. 协议绑定", result2.Item2); + + var result3 = await _service.ExecuteAsync("7", chatId); + Assert.True(result3.Item1); + Assert.Contains("5. http://test.com/v1 (OpenAIResponses/Bearer)", result3.Item2); + Assert.Contains("0 新增绑定", result3.Item2); + + var result4 = await _service.ExecuteAsync("5", chatId); + + // Assert + Assert.True(result4.Item1); + Assert.Contains("设为默认绑定", result4.Item2); + helperMock.Verify(h => h.PromoteBinding(1, 5), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_EditChannelBindings_AddsNewBinding() { + // Arrange + long chatId = 123; + var existing = new LLMApiBinding { Id = 3, LLMChannelId = 1, Endpoint = "https://opencode.ai/zen/v1", Protocol = LlmProtocol.OpenAIChat, AuthProfile = LlmAuthProfile.Bearer, IsDefault = true }; + + _dbMock.SetupSequence(d => d.StringGetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("editing_select_field").ReturnsAsync("1") + .ReturnsAsync("editing_select_binding").ReturnsAsync("1|7") + .ReturnsAsync("editing_binding_endpoint").ReturnsAsync("1|new") + .ReturnsAsync("editing_binding_protocol").ReturnsAsync("1|https://opencode.ai/zen/v1") + .ReturnsAsync("editing_binding_auth").ReturnsAsync("1|https://opencode.ai/zen/v1|OpenAIResponses"); + + helperMock.Setup(h => h.GetBindings(1)).ReturnsAsync(new List { existing }); + helperMock.Setup(h => h.EnsureBinding(1, "https://opencode.ai/zen/v1", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer)).ReturnsAsync(55); + + // Act + var openBindings = await _service.ExecuteAsync("7", chatId); + Assert.Contains("3. https://opencode.ai/zen/v1", openBindings.Item2); + + var askEndpoint = await _service.ExecuteAsync("0", chatId); + Assert.Contains("请输入新绑定的端点地址", askEndpoint.Item2); + + var askProtocol = await _service.ExecuteAsync("https://opencode.ai/zen/v1", chatId); + Assert.Contains("请选择线协议", askProtocol.Item2); + Assert.Contains("2. OpenAIResponses", askProtocol.Item2); + + var askAuth = await _service.ExecuteAsync("2", chatId); + Assert.Contains("请选择认证方式", askAuth.Item2); + + var created = await _service.ExecuteAsync("1", chatId); + + // Assert + Assert.True(created.Item1); + Assert.Contains("绑定创建成功(ID: 55", created.Item2); + helperMock.Verify(h => h.EnsureBinding(1, "https://opencode.ai/zen/v1", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer), Times.Once); + } } } diff --git a/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs b/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs index 528e5545..d3740f14 100644 --- a/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs +++ b/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs @@ -37,5 +37,20 @@ public interface IEditLLMConfHelper { /// 把模型行改挂到指定 binding,并标记为模型级协议覆盖;成功返回 true。 Task AssignModelBinding(int channelId, string modelName, int bindingId); + + /// 渠道下的全部协议绑定(按 Id 升序)。 + Task> GetBindings(int channelId); + + /// 把指定 binding 设为渠道默认(其余降级),并镜像 channel.Gateway/Provider;成功返回 true。 + Task PromoteBinding(int channelId, int bindingId); + + /// + /// 渠道下的模型行(按行 Id),展示格式与 GetModelsByChannelId 一致(多 binding 时带 [channel/binding/protocol]); + /// 移除模型按行 Id 删除,避免多 binding 下删错行。 + /// + Task> GetModelRowsByChannelId(long channelId); + + /// 按行 Id 删除模型行;行不存在返回 false。 + Task RemoveModelRow(long rowId); } } diff --git a/TelegramSearchBot/Model/AI/LLMConfState.cs b/TelegramSearchBot/Model/AI/LLMConfState.cs index 1734008a..723d9954 100644 --- a/TelegramSearchBot/Model/AI/LLMConfState.cs +++ b/TelegramSearchBot/Model/AI/LLMConfState.cs @@ -32,6 +32,18 @@ public enum LLMConfState { [Description("editing_select_channel")] EditingSelectChannel, + [Description("editing_select_binding")] + EditingSelectBinding, + + [Description("editing_binding_endpoint")] + EditingBindingEndpoint, + + [Description("editing_binding_protocol")] + EditingBindingProtocol, + + [Description("editing_binding_auth")] + EditingBindingAuth, + [Description("adding_model_select_channel")] AddingModelSelectChannel, diff --git a/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs b/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs index 9604212e..d50a5b8e 100644 --- a/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs +++ b/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs @@ -761,6 +761,82 @@ public async Task AssignModelBinding(int channelId, string modelName, int } } + /// 渠道下的全部协议绑定(按 Id 升序)。 + public async Task> GetBindings(int channelId) { + try { + return await DataContext.LLMApiBindings + .Where(b => b.LLMChannelId == channelId) + .OrderBy(b => b.Id) + .ToListAsync(); + } catch { + return new List(); + } + } + + /// 把指定 binding 设为渠道默认(其余降级),并镜像 channel.Gateway/Provider(blueprint §七)。 + public async Task PromoteBinding(int channelId, int bindingId) { + try { + var channel = await DataContext.LLMChannels.Include(c => c.Bindings).FirstOrDefaultAsync(c => c.Id == channelId); + if (channel == null) { + return false; + } + + var target = channel.Bindings.FirstOrDefault(b => b.Id == bindingId); + if (target == null) { + _logger.LogWarning("设置默认 binding 失败:渠道 {ChannelId} 不存在 binding {BindingId}", channelId, bindingId); + return false; + } + + foreach (var binding in channel.Bindings) { + binding.IsDefault = binding.Id == target.Id; + } + channel.Gateway = target.Endpoint; + channel.Provider = MapProtocolToProvider(target.Protocol); + await DataContext.SaveChangesAsync(); + return true; + } catch (Exception ex) { + _logger.LogError(ex, "把 binding {BindingId} 设为渠道 {ChannelId} 默认失败", bindingId, channelId); + return false; + } + } + + /// 渠道下的模型行(按行 Id),展示格式与 GetModelsByChannelId 一致(多 binding 带协议标注)。 + public async Task> GetModelRowsByChannelId(long channelId) { + var rows = await DataContext.ChannelsWithModel + .Include(c => c.ApiBinding) + .Include(c => c.LLMChannel) + .Where(c => c.LLMChannelId == channelId && !c.IsDeleted) + .OrderBy(c => c.ModelName).ThenBy(c => c.Id) + .ToListAsync(); + + var multiBindingNames = rows + .GroupBy(r => r.ModelName, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return rows + .Select(r => ((long)r.Id, r.ModelName, multiBindingNames.Contains(r.ModelName) ? FormatModelDisplay(r) : r.ModelName)) + .ToList(); + } + + /// 按行 Id 删除模型行(多 binding 下不会误删同名的其它行)。 + public async Task RemoveModelRow(long rowId) { + try { + var row = await DataContext.ChannelsWithModel.FirstOrDefaultAsync(m => m.Id == rowId); + if (row == null) { + return false; + } + + DataContext.ChannelsWithModel.Remove(row); + await DataContext.SaveChangesAsync(); + return true; + } catch (Exception ex) { + _logger.LogError(ex, "删除模型行 {RowId} 失败", rowId); + return false; + } + } + /// /// channel Gateway/Provider 变更时同步默认 binding(镜像规则,blueprint §七)。 /// 无默认 binding 时不创建(补建由 EnsureDefaultBinding 在 AddChannel/RefreshAllChannel 负责)。 diff --git a/TelegramSearchBot/Service/Manage/EditLLMConfService.cs b/TelegramSearchBot/Service/Manage/EditLLMConfService.cs index 2a268d48..41d341bd 100644 --- a/TelegramSearchBot/Service/Manage/EditLLMConfService.cs +++ b/TelegramSearchBot/Service/Manage/EditLLMConfService.cs @@ -61,6 +61,10 @@ MusicGenerationToolSettingsService musicGenerationToolSettingsService { LLMConfState.SettingAltPhotoModel.GetDescription(), HandleSettingAltPhotoModelAsync }, { LLMConfState.EditingSelectChannel.GetDescription(), HandleEditingSelectChannelAsync }, { LLMConfState.EditingSelectField.GetDescription(), HandleEditingSelectFieldAsync }, + { LLMConfState.EditingSelectBinding.GetDescription(), HandleEditingSelectBindingAsync }, + { LLMConfState.EditingBindingEndpoint.GetDescription(), HandleEditingBindingEndpointAsync }, + { LLMConfState.EditingBindingProtocol.GetDescription(), HandleEditingBindingProtocolAsync }, + { LLMConfState.EditingBindingAuth.GetDescription(), HandleEditingBindingAuthAsync }, { LLMConfState.AddingModelSelectChannel.GetDescription(), HandleAddingModelSelectChannelAsync }, { LLMConfState.AddingModelInput.GetDescription(), HandleAddingModelInputAsync }, { LLMConfState.RemovingModelSelectChannel.GetDescription(), HandleRemovingModelSelectChannelAsync }, @@ -341,7 +345,7 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration await redis.SetDataAsync(channelId.ToString()); await redis.SetStateAsync(LLMConfState.EditingSelectField.GetDescription()); - return (true, $"请选择要编辑的字段:\n1. 名称 ({channel.Name})\n2. 地址 ({channel.Gateway})\n3. 类型 ({channel.Provider})\n4. API Key\n5. 最大并行数量 ({channel.Parallel})\n6. 优先级 ({channel.Priority})"); + return (true, $"请选择要编辑的字段:\n1. 名称 ({channel.Name})\n2. 地址 ({channel.Gateway})\n3. 类型 ({channel.Provider})\n4. API Key\n5. 最大并行数量 ({channel.Parallel})\n6. 优先级 ({channel.Priority})\n7. 协议绑定"); } private async Task<(bool, string)> HandleEditingSelectFieldAsync(EditLLMConfRedisHelper redis, string command) { @@ -349,6 +353,10 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration var editChannelId = int.Parse(value); await redis.SetDataAsync($"{editChannelId}|{command}"); + if (command == "7") { + return await ShowBindingSelectionAsync(redis, editChannelId); + } + if (command == "3") { await redis.SetStateAsync(LLMConfState.EditingInputValue.GetDescription()); @@ -369,6 +377,135 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration } } + /// 列出渠道的协议绑定并询问操作(输入编号设为默认,0 新增)。 + private async Task<(bool, string)> ShowBindingSelectionAsync(EditLLMConfRedisHelper redis, int channelId) { + var bindings = await Helper.GetBindings(channelId); + if (bindings.Count == 0) { + await redis.DeleteKeysAsync(); + return (true, "该渠道没有任何协议绑定"); + } + + var sb = new StringBuilder($"渠道 {channelId} 的协议绑定:\n"); + foreach (var binding in bindings) { + var marker = binding.IsDefault ? " [默认]" : string.Empty; + sb.AppendLine($"{binding.Id}. {binding.Endpoint} ({binding.Protocol}/{binding.AuthProfile}){marker}"); + } + sb.Append("请输入要设为默认的绑定ID,或发送 0 新增绑定:"); + + await redis.SetStateAsync(LLMConfState.EditingSelectBinding.GetDescription()); + return (true, sb.ToString()); + } + + private async Task<(bool, string)> HandleEditingSelectBindingAsync(EditLLMConfRedisHelper redis, string command) { + var data = await redis.GetDataAsync(); + var parts = data.Split('|'); + if (parts.Length < 2 || !int.TryParse(parts[0], out var channelId)) { + await redis.DeleteKeysAsync(); + return (true, "内部错误:渠道状态丢失"); + } + + var bindings = await Helper.GetBindings(channelId); + var cmd = command.Trim(); + + if (cmd == "0" || cmd.Equals("新增", StringComparison.OrdinalIgnoreCase)) { + await redis.SetDataAsync($"{channelId}|new"); + await redis.SetStateAsync(LLMConfState.EditingBindingEndpoint.GetDescription()); + return (true, "请输入新绑定的端点地址(例如 https://opencode.ai/zen/v1):"); + } + + if (!int.TryParse(cmd, out var bindingId) || bindings.All(b => b.Id != bindingId)) { + return (false, "请输入列表中的绑定ID,或发送 0 新增绑定"); + } + + if (!await Helper.PromoteBinding(channelId, bindingId)) { + await redis.DeleteKeysAsync(); + return (true, "设置为默认绑定失败"); + } + + await redis.DeleteKeysAsync(); + return (true, $"已将 binding {bindingId} 设为默认绑定(渠道地址与类型已同步)。"); + } + + private async Task<(bool, string)> HandleEditingBindingEndpointAsync(EditLLMConfRedisHelper redis, string command) { + var data = await redis.GetDataAsync(); + var parts = data.Split('|'); + if (parts.Length < 2 || !int.TryParse(parts[0], out var channelId)) { + await redis.DeleteKeysAsync(); + return (true, "内部错误:渠道状态丢失"); + } + + var endpoint = command.Trim(); + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) || + ( uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps )) { + return (false, "请输入有效的端点地址(以 http:// 或 https:// 开头)"); + } + + await redis.SetDataAsync($"{channelId}|{endpoint}"); + await redis.SetStateAsync(LLMConfState.EditingBindingProtocol.GetDescription()); + return (true, FormatProtocolOptions()); + } + + private async Task<(bool, string)> HandleEditingBindingProtocolAsync(EditLLMConfRedisHelper redis, string command) { + var data = await redis.GetDataAsync(); + var parts = data.Split('|'); + if (parts.Length < 2 || !int.TryParse(parts[0], out var channelId)) { + await redis.DeleteKeysAsync(); + return (true, "内部错误:渠道状态丢失"); + } + + var protocols = Enum.GetValues(); + if (!int.TryParse(command.Trim(), out var index) || index < 1 || index > protocols.Length) { + return (false, $"请输入 1 到 {protocols.Length} 之间的数字"); + } + + await redis.SetDataAsync($"{channelId}|{parts[1]}|{protocols[index - 1]}"); + await redis.SetStateAsync(LLMConfState.EditingBindingAuth.GetDescription()); + return (true, FormatAuthOptions()); + } + + private async Task<(bool, string)> HandleEditingBindingAuthAsync(EditLLMConfRedisHelper redis, string command) { + var data = await redis.GetDataAsync(); + var parts = data.Split('|', 3); + if (parts.Length < 3 || !int.TryParse(parts[0], out var channelId)) { + await redis.DeleteKeysAsync(); + return (true, "内部错误:绑定状态丢失"); + } + + var authProfiles = Enum.GetValues(); + if (!int.TryParse(command.Trim(), out var index) || index < 1 || index > authProfiles.Length) { + return (false, $"请输入 1 到 {authProfiles.Length} 之间的数字"); + } + + if (!Enum.TryParse(parts[2], out var protocol)) { + await redis.DeleteKeysAsync(); + return (true, "内部错误:协议解析失败"); + } + + var bindingId = await Helper.EnsureBinding(channelId, parts[1], protocol, authProfiles[index - 1]); + await redis.DeleteKeysAsync(); + return bindingId > 0 + ? (true, $"绑定创建成功(ID: {bindingId},{protocol}/{authProfiles[index - 1]})。可用 `编辑渠道` 第 7 项把它设为默认。") + : (true, "绑定创建失败"); + } + + private static string FormatProtocolOptions() { + var protocols = Enum.GetValues(); + var sb = new StringBuilder("请选择线协议:\n"); + for (var i = 0; i < protocols.Length; i++) { + sb.AppendLine($"{i + 1}. {protocols[i]}"); + } + return sb.ToString().TrimEnd(); + } + + private static string FormatAuthOptions() { + var authProfiles = Enum.GetValues(); + var sb = new StringBuilder("请选择认证方式:\n"); + for (var i = 0; i < authProfiles.Length; i++) { + sb.AppendLine($"{i + 1}. {authProfiles[i]}"); + } + return sb.ToString().TrimEnd(); + } + private async Task<(bool, string)> HandleAddingModelSelectChannelAsync(EditLLMConfRedisHelper redis, string command) { if (!int.TryParse(command, out var addModelChannelId)) { return (false, "请输入有效的渠道ID"); @@ -404,23 +541,20 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration return (true, "找不到指定的渠道"); } - // 获取该渠道下的所有模型 - var models = await DataContext.ChannelsWithModel - .Where(m => m.LLMChannelId == removeModelChannelId && !m.IsDeleted) - .Select(m => m.ModelName) - .ToListAsync(); + // 获取该渠道下的所有模型行(多 binding 时带协议标注;按行 Id 删除避免误删同名行) + var modelRows = await Helper.GetModelRowsByChannelId(removeModelChannelId); - if (models.Count == 0) { + if (modelRows.Count == 0) { return (true, "该渠道下没有可移除的模型"); } var sb = new StringBuilder(); sb.AppendLine("请选择要移除的模型:"); - for (int i = 0; i < models.Count; i++) { - sb.AppendLine($"{i + 1}. {models[i]}"); + for (int i = 0; i < modelRows.Count; i++) { + sb.AppendLine($"{i + 1}. {modelRows[i].Display}"); } - await redis.SetDataAsync($"{removeModelChannelId}|{string.Join(",", models)}"); + await redis.SetDataAsync($"{removeModelChannelId}|{string.Join(",", modelRows.Select(r => r.RowId))}"); await redis.SetStateAsync(LLMConfState.RemovingModelSelect.GetDescription()); return (true, sb.ToString()); } @@ -433,23 +567,21 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration return (false, "内部错误:模型数据格式不正确"); } - var removeChannelId = int.Parse(parts[0]); - var modelList = parts[1].Split(','); + var rowIds = parts[1].Split(',', StringSplitOptions.RemoveEmptyEntries); - if (modelList.Length == 0 || ( modelList.Length == 1 && string.IsNullOrEmpty(modelList[0]) )) { + if (rowIds.Length == 0) { return (true, "该渠道下没有可移除的模型"); } - if (!int.TryParse(command, out var modelIndex) || modelIndex < 1 || modelIndex > modelList.Length) { + if (!int.TryParse(command, out var modelIndex) || modelIndex < 1 || modelIndex > rowIds.Length) { return (true, "请输入有效的模型序号"); } - if (modelIndex - 1 < 0 || modelIndex - 1 >= modelList.Length) { - return (true, "内部错误:无效的模型序号"); + if (!long.TryParse(rowIds[modelIndex - 1], out var rowId)) { + return (true, "内部错误:模型行ID无效"); } - var modelName = modelList[modelIndex - 1]; - var removeResult = await Helper.RemoveModelFromChannel(removeChannelId, modelName); + var removeResult = await Helper.RemoveModelRow(rowId); // 清理状态 await redis.DeleteKeysAsync();