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