From 5f216b90acec6db5579484d404b85f5ff90e54b3 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Fri, 14 Aug 2026 19:59:43 +0800 Subject: [PATCH 1/2] Add OpenCode Go/Zen API binding support - New LLMApiBinding entity (endpoint/protocol/auth profile) with LlmProtocol, LlmAuthProfile, AuthorizationSource enums; nullable ApiBindingId, AuthorizationSource, IsPreferred on ChannelWithModel - EF migration with per-channel default-binding backfill (nullable, all legacy fields preserved, no NOCASE unique index) - Deterministic routing shared by General and Agent paths: channel Priority DESC -> IsPreferred -> binding IsDefault -> stable binding.Id sort; legacy rows fall back to channel Provider/Gateway with warning - Factory selects existing clients by binding protocol; endpoint/auth from binding, shared ApiKey from channel; Bearer vs x-api-key isolation - Management: OpenCode /models treated as catalog, never creates or deletes authorization rows; Manual rows never soft-deleted; metadata merges only onto existing rows; case-insensitive dedup; default-binding repair and Gateway/Provider mirroring; multi-binding display [channel/binding/protocol] - Protocol-level wire tests (Go/Zen x Chat/Responses/Messages) with loopback server; upgrade rehearsal and binary rollback drill verified - Fix OpenAI Responses streaming flag (SDK 2.10) and Anthropic base URL derivation for binding endpoints (legacy paths unchanged) --- .../Model/AI/AuthorizationSource.cs | 10 + .../Model/AI/LlmAgentContracts.cs | 6 + .../Model/AI/LlmAuthProfile.cs | 10 + .../Model/AI/LlmProtocol.cs | 13 + ...0260814085820_AddLlmApiBinding.Designer.cs | 956 ++++++++++++++++++ .../20260814085820_AddLlmApiBinding.cs | 133 +++ .../Migrations/DataDbContextModelSnapshot.cs | 58 ++ .../Model/Data/ChannelWithModel.cs | 18 + .../Model/Data/LLMApiBinding.cs | 23 + .../Model/Data/LLMChannel.cs | 5 + .../Model/DataDbContext.cs | 15 + .../Service/AI/LLM/GeneralLLMServiceTests.cs | 320 ++++++ .../Service/AI/LLM/LLMFactoryTests.cs | 146 +++ .../AI/LLM/ModelCapabilityServiceTests.cs | 116 +++ .../AI/LLM/OpenCodeWireProtocolTests.cs | 688 +++++++++++++ .../Interface/AI/LLM/IGeneralLLMService.cs | 2 +- .../Interface/AI/LLM/ILLMFactory.cs | 9 + .../Interface/AI/LLM/ILLMService.cs | 29 + .../Service/AI/LLM/AnthropicService.cs | 64 +- .../Service/AI/LLM/GeneralLLMService.cs | 73 +- .../Service/AI/LLM/LLMFactory.cs | 17 + .../Service/AI/LLM/LlmRouteResolver.cs | 115 +++ .../Service/AI/LLM/ModelCapabilityService.cs | 66 +- .../Service/AI/LLM/OllamaService.cs | 52 +- .../Service/AI/LLM/OpenAIResponsesService.cs | 96 +- .../Service/AI/LLM/OpenAIService.cs | 126 ++- .../Service/LlmServiceProxy.cs | 37 +- .../Manage/EditLLMConfHelperTest.cs | 339 ++++++- .../Manage/EditLLMConfTest.cs | 43 + .../AI/LLM/LLMTaskQueueServiceTests.cs | 245 +++++ .../Service/Database/LlmApiBindingTests.cs | 249 +++++ .../Interface/Manage/IEditLLMConfHelper.cs | 12 + .../Service/AI/LLM/LLMTaskQueueService.cs | 55 +- .../Service/Manage/EditLLMConfHelper.cs | 367 +++++-- .../Service/Manage/EditLLMConfService.cs | 7 +- 35 files changed, 4308 insertions(+), 212 deletions(-) create mode 100644 TelegramSearchBot.Common/Model/AI/AuthorizationSource.cs create mode 100644 TelegramSearchBot.Common/Model/AI/LlmAuthProfile.cs create mode 100644 TelegramSearchBot.Common/Model/AI/LlmProtocol.cs create mode 100644 TelegramSearchBot.Database/Migrations/20260814085820_AddLlmApiBinding.Designer.cs create mode 100644 TelegramSearchBot.Database/Migrations/20260814085820_AddLlmApiBinding.cs create mode 100644 TelegramSearchBot.Database/Model/Data/LLMApiBinding.cs create mode 100644 TelegramSearchBot.LLM.Test/Service/AI/LLM/OpenCodeWireProtocolTests.cs create mode 100644 TelegramSearchBot.LLM/Service/AI/LLM/LlmRouteResolver.cs create mode 100644 TelegramSearchBot.Test/Service/Database/LlmApiBindingTests.cs diff --git a/TelegramSearchBot.Common/Model/AI/AuthorizationSource.cs b/TelegramSearchBot.Common/Model/AI/AuthorizationSource.cs new file mode 100644 index 00000000..f2f60f9b --- /dev/null +++ b/TelegramSearchBot.Common/Model/AI/AuthorizationSource.cs @@ -0,0 +1,10 @@ +namespace TelegramSearchBot.Model.AI { + /// + /// ChannelWithModel 授权来源。Manual = 管理员手工添加,永不被刷新/目录结果软删; + /// Discovered = 来自授权快照来源,仅真正快照刷新成功后处理。 + /// + public enum AuthorizationSource { + Manual = 0, + Discovered = 1 + } +} diff --git a/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs b/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs index e9b1cc2e..1cf7dd46 100644 --- a/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs +++ b/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs @@ -76,6 +76,12 @@ public sealed class AgentChannelConfig { public int Priority { get; set; } public string ModelName { get; set; } = string.Empty; public List Capabilities { get; set; } = []; + + // Phase 2:解析出的 binding 快照(旧二进制/legacy 行全部为 null,走 Provider/Gateway)。 + public int? BindingId { get; set; } + public string BindingEndpoint { get; set; } = string.Empty; + public LlmProtocol? BindingProtocol { get; set; } + public LlmAuthProfile? BindingAuthProfile { get; set; } } public sealed class AgentExecutionTask { diff --git a/TelegramSearchBot.Common/Model/AI/LlmAuthProfile.cs b/TelegramSearchBot.Common/Model/AI/LlmAuthProfile.cs new file mode 100644 index 00000000..ae5bd112 --- /dev/null +++ b/TelegramSearchBot.Common/Model/AI/LlmAuthProfile.cs @@ -0,0 +1,10 @@ +namespace TelegramSearchBot.Model.AI { + /// + /// API binding 的认证方式。key 本体仍共享自 LLMChannel.ApiKey。 + /// + public enum LlmAuthProfile { + Bearer = 0, + AnthropicApiKey = 1, + None = 2 + } +} diff --git a/TelegramSearchBot.Common/Model/AI/LlmProtocol.cs b/TelegramSearchBot.Common/Model/AI/LlmProtocol.cs new file mode 100644 index 00000000..3cc83340 --- /dev/null +++ b/TelegramSearchBot.Common/Model/AI/LlmProtocol.cs @@ -0,0 +1,13 @@ +namespace TelegramSearchBot.Model.AI { + /// + /// API binding 的线协议。与 LLMProvider(品牌/订阅账号)语义分离, + /// 同一 channel 可通过多个 binding 支持不同协议。 + /// + public enum LlmProtocol { + OpenAIChat = 0, + OpenAIResponses = 1, + AnthropicMessages = 2, + Ollama = 3, + Gemini = 4 + } +} diff --git a/TelegramSearchBot.Database/Migrations/20260814085820_AddLlmApiBinding.Designer.cs b/TelegramSearchBot.Database/Migrations/20260814085820_AddLlmApiBinding.Designer.cs new file mode 100644 index 00000000..d4653c6f --- /dev/null +++ b/TelegramSearchBot.Database/Migrations/20260814085820_AddLlmApiBinding.Designer.cs @@ -0,0 +1,956 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TelegramSearchBot.Model; + +#nullable disable + +namespace TelegramSearchBot.Migrations +{ + [DbContext(typeof(DataDbContext))] + [Migration("20260814085820_AddLlmApiBinding")] + partial class AddLlmApiBinding + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.AccountBook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId", "Name") + .IsUnique(); + + b.ToTable("AccountBooks"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.AccountRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountBookId") + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUsername") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tag"); + + b.HasIndex("AccountBookId", "CreatedAt"); + + b.ToTable("AccountRecords"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.AppConfigurationItem", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("AppConfigurationItems"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ChannelWithModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiBindingId") + .HasColumnType("INTEGER"); + + b.Property("AuthorizationSource") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPreferred") + .HasColumnType("INTEGER"); + + b.Property("LLMChannelId") + .HasColumnType("INTEGER"); + + b.Property("ModelName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApiBindingId"); + + b.HasIndex("LLMChannelId"); + + b.ToTable("ChannelsWithModel"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ConversationSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentSummary") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("FirstMessageId") + .HasColumnType("INTEGER"); + + b.Property("FullContent") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsVectorized") + .HasColumnType("INTEGER"); + + b.Property("LastMessageId") + .HasColumnType("INTEGER"); + + b.Property("MessageCount") + .HasColumnType("INTEGER"); + + b.Property("ParticipantCount") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("TopicKeywords") + .HasColumnType("TEXT"); + + b.Property("VectorId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId", "StartTime", "EndTime"); + + b.ToTable("ConversationSegments"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ConversationSegmentMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConversationSegmentId") + .HasColumnType("INTEGER"); + + b.Property("MessageDataId") + .HasColumnType("INTEGER"); + + b.Property("SequenceOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ConversationSegmentId"); + + b.HasIndex("MessageDataId"); + + b.ToTable("ConversationSegmentMessages"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.FaissIndexFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Dimension") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IndexType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("IsValid") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VectorCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId", "IndexType") + .IsUnique(); + + b.ToTable("FaissIndexFiles"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.GroupAccountSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActiveAccountBookId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsAccountingEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.ToTable("GroupAccountSettings"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.GroupData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsBlacklist") + .HasColumnType("INTEGER"); + + b.Property("IsForum") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GroupData"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.GroupSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentChatBatchWindowSeconds") + .HasColumnType("INTEGER"); + + b.Property("AgentChatMode") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("ImageGenerationModelName") + .HasColumnType("TEXT"); + + b.Property("IsAgentChatEnabled") + .HasColumnType("INTEGER"); + + b.Property("IsManagerGroup") + .HasColumnType("INTEGER"); + + b.Property("LLMModelName") + .HasColumnType("TEXT"); + + b.Property("MusicGenerationModelName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.ToTable("GroupSettings"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.LLMApiBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthProfile") + .HasColumnType("INTEGER"); + + b.Property("Endpoint") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LLMChannelId") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LLMChannelId"); + + b.ToTable("LLMApiBindings"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.LLMChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Parallel") + .HasColumnType("INTEGER"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("LLMChannels"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.MemoryGraph", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChatId") + .HasColumnType("INTEGER"); + + b.Property("CreatedTime") + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FromEntity") + .HasColumnType("TEXT"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Observations") + .HasColumnType("TEXT"); + + b.Property("RelationType") + .HasColumnType("TEXT"); + + b.Property("ToEntity") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MemoryGraphs"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("DateTime") + .HasColumnType("TEXT"); + + b.Property("FromUserId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("MessageId") + .HasColumnType("INTEGER"); + + b.Property("ReplyToMessageId") + .HasColumnType("INTEGER"); + + b.Property("ReplyToUserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.MessageExtension", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MessageDataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MessageDataId"); + + b.ToTable("MessageExtensions"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ModelCapability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapabilityName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CapabilityValue") + .HasColumnType("TEXT"); + + b.Property("ChannelWithModelId") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChannelWithModelId"); + + b.ToTable("ModelCapabilities"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ScheduledTaskExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompletedTime") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastHeartbeat") + .HasColumnType("TEXT"); + + b.Property("ResultSummary") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TaskName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TaskName") + .IsUnique(); + + b.ToTable("ScheduledTaskExecutions"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.SearchPageCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedTime") + .HasColumnType("TEXT"); + + b.Property("SearchOptionJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UUID") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SearchPageCaches"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ShortUrlMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExpandedUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginalUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OriginalUrl"); + + b.ToTable("ShortUrlMappings"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.TelegramFileCacheEntry", b => + { + b.Property("CacheKey") + .HasColumnType("TEXT"); + + b.Property("ExpiryDate") + .HasColumnType("TEXT"); + + b.Property("FileId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("CacheKey"); + + b.HasIndex("CacheKey") + .IsUnique(); + + b.ToTable("TelegramFileCacheEntries"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.TodoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChatId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CompletedBy") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("DueAtUtc") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("RemindAtUtc") + .HasColumnType("TEXT"); + + b.Property("ReminderMessageId") + .HasColumnType("INTEGER"); + + b.Property("ReminderSentAtUtc") + .HasColumnType("TEXT"); + + b.Property("SourceMessageId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TodoListId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TodoListId", "Status"); + + b.HasIndex("ChatId", "Status", "RemindAtUtc", "ReminderSentAtUtc"); + + b.ToTable("TodoItems"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.TodoList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChatId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChatId", "Name") + .IsUnique(); + + b.ToTable("TodoLists"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.UserData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("IsBot") + .HasColumnType("INTEGER"); + + b.Property("IsPremium") + .HasColumnType("INTEGER"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UserData"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.UserWithGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsLlmInvisible") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId", "IsLlmInvisible"); + + b.HasIndex("UserId", "GroupId") + .IsUnique(); + + b.ToTable("UsersWithGroup"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.VectorIndex", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentSummary") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("FaissIndex") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VectorType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId", "FaissIndex"); + + b.HasIndex("GroupId", "VectorType", "EntityId") + .IsUnique(); + + b.ToTable("VectorIndexes"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.AccountRecord", b => + { + b.HasOne("TelegramSearchBot.Model.Data.AccountBook", "AccountBook") + .WithMany("Records") + .HasForeignKey("AccountBookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AccountBook"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ChannelWithModel", b => + { + b.HasOne("TelegramSearchBot.Model.Data.LLMApiBinding", "ApiBinding") + .WithMany() + .HasForeignKey("ApiBindingId"); + + b.HasOne("TelegramSearchBot.Model.Data.LLMChannel", "LLMChannel") + .WithMany("Models") + .HasForeignKey("LLMChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApiBinding"); + + b.Navigation("LLMChannel"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ConversationSegmentMessage", b => + { + b.HasOne("TelegramSearchBot.Model.Data.ConversationSegment", "ConversationSegment") + .WithMany("Messages") + .HasForeignKey("ConversationSegmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramSearchBot.Model.Data.Message", "Message") + .WithMany() + .HasForeignKey("MessageDataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ConversationSegment"); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.LLMApiBinding", b => + { + b.HasOne("TelegramSearchBot.Model.Data.LLMChannel", "LLMChannel") + .WithMany("Bindings") + .HasForeignKey("LLMChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LLMChannel"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.MessageExtension", b => + { + b.HasOne("TelegramSearchBot.Model.Data.Message", "Message") + .WithMany("MessageExtensions") + .HasForeignKey("MessageDataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ModelCapability", b => + { + b.HasOne("TelegramSearchBot.Model.Data.ChannelWithModel", "ChannelWithModel") + .WithMany("Capabilities") + .HasForeignKey("ChannelWithModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChannelWithModel"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.TodoItem", b => + { + b.HasOne("TelegramSearchBot.Model.Data.TodoList", "TodoList") + .WithMany("Items") + .HasForeignKey("TodoListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TodoList"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.AccountBook", b => + { + b.Navigation("Records"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ChannelWithModel", b => + { + b.Navigation("Capabilities"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.ConversationSegment", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.LLMChannel", b => + { + b.Navigation("Bindings"); + + b.Navigation("Models"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.Message", b => + { + b.Navigation("MessageExtensions"); + }); + + modelBuilder.Entity("TelegramSearchBot.Model.Data.TodoList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TelegramSearchBot.Database/Migrations/20260814085820_AddLlmApiBinding.cs b/TelegramSearchBot.Database/Migrations/20260814085820_AddLlmApiBinding.cs new file mode 100644 index 00000000..c3010c76 --- /dev/null +++ b/TelegramSearchBot.Database/Migrations/20260814085820_AddLlmApiBinding.cs @@ -0,0 +1,133 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramSearchBot.Migrations +{ + /// + public partial class AddLlmApiBinding : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ApiBindingId", + table: "ChannelsWithModel", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "AuthorizationSource", + table: "ChannelsWithModel", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "IsPreferred", + table: "ChannelsWithModel", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "LLMApiBindings", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + LLMChannelId = table.Column(type: "INTEGER", nullable: false), + Endpoint = table.Column(type: "TEXT", nullable: true), + Protocol = table.Column(type: "INTEGER", nullable: false), + AuthProfile = table.Column(type: "INTEGER", nullable: false), + IsDefault = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LLMApiBindings", x => x.Id); + table.ForeignKey( + name: "FK_LLMApiBindings_LLMChannels_LLMChannelId", + column: x => x.LLMChannelId, + principalTable: "LLMChannels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChannelsWithModel_ApiBindingId", + table: "ChannelsWithModel", + column: "ApiBindingId"); + + migrationBuilder.CreateIndex( + name: "IX_LLMApiBindings_LLMChannelId", + table: "LLMApiBindings", + column: "LLMChannelId"); + + migrationBuilder.AddForeignKey( + name: "FK_ChannelsWithModel_LLMApiBindings_ApiBindingId", + table: "ChannelsWithModel", + column: "ApiBindingId", + principalTable: "LLMApiBindings", + principalColumn: "Id"); + + // ---- 数据回填(每个旧 channel 恰好一个 default binding;模型回填绑定)---- + // 协议映射:OpenAI/MiniMax/LMStudio→OpenAIChat(0);ResponsesAPI→OpenAIResponses(1); + // Anthropic→AnthropicMessages(2);Ollama→Ollama(3);Gemini→Gemini(4);其余→OpenAIChat(0)。 + // 认证映射:Anthropic→AnthropicApiKey(1);Ollama→None(2)(OllamaService 源码证明 keyless);其余→Bearer(0)。 + migrationBuilder.Sql(@" +INSERT INTO LLMApiBindings (LLMChannelId, Endpoint, Protocol, AuthProfile, IsDefault) +SELECT Id, Gateway, + CASE Provider + WHEN 1 THEN 0 -- OpenAI -> OpenAIChat + WHEN 2 THEN 3 -- Ollama -> Ollama + WHEN 3 THEN 4 -- Gemini -> Gemini + WHEN 4 THEN 0 -- MiniMax -> OpenAIChat + WHEN 5 THEN 0 -- LMStudio -> OpenAIChat + WHEN 6 THEN 2 -- Anthropic -> AnthropicMessages + WHEN 7 THEN 1 -- ResponsesAPI -> OpenAIResponses + ELSE 0 + END, + CASE Provider + WHEN 6 THEN 1 -- Anthropic -> AnthropicApiKey + WHEN 2 THEN 2 -- Ollama -> None(keyless) + ELSE 0 -- 其余 -> Bearer + END, + 1 +FROM LLMChannels;"); + + // 回填旧模型行:指向其 channel 的 default binding;孤儿行保持 NULL(legacy fallback) + migrationBuilder.Sql(@" +UPDATE ChannelsWithModel +SET ApiBindingId = (SELECT b.Id FROM LLMApiBindings b + WHERE b.LLMChannelId = ChannelsWithModel.LLMChannelId + AND b.IsDefault = 1 LIMIT 1);"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ChannelsWithModel_LLMApiBindings_ApiBindingId", + table: "ChannelsWithModel"); + + migrationBuilder.DropTable( + name: "LLMApiBindings"); + + migrationBuilder.DropIndex( + name: "IX_ChannelsWithModel_ApiBindingId", + table: "ChannelsWithModel"); + + migrationBuilder.DropColumn( + name: "ApiBindingId", + table: "ChannelsWithModel"); + + migrationBuilder.DropColumn( + name: "AuthorizationSource", + table: "ChannelsWithModel"); + + migrationBuilder.DropColumn( + name: "IsPreferred", + table: "ChannelsWithModel"); + } + } +} diff --git a/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs b/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs index 366cec3b..698d9652 100644 --- a/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs +++ b/TelegramSearchBot.Database/Migrations/DataDbContextModelSnapshot.cs @@ -111,9 +111,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("ApiBindingId") + .HasColumnType("INTEGER"); + + b.Property("AuthorizationSource") + .HasColumnType("INTEGER"); + b.Property("IsDeleted") .HasColumnType("INTEGER"); + b.Property("IsPreferred") + .HasColumnType("INTEGER"); + b.Property("LLMChannelId") .HasColumnType("INTEGER"); @@ -122,6 +131,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("ApiBindingId"); + b.HasIndex("LLMChannelId"); b.ToTable("ChannelsWithModel"); @@ -332,6 +343,34 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("GroupSettings"); }); + modelBuilder.Entity("TelegramSearchBot.Model.Data.LLMApiBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthProfile") + .HasColumnType("INTEGER"); + + b.Property("Endpoint") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LLMChannelId") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LLMChannelId"); + + b.ToTable("LLMApiBindings"); + }); + modelBuilder.Entity("TelegramSearchBot.Model.Data.LLMChannel", b => { b.Property("Id") @@ -799,12 +838,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("TelegramSearchBot.Model.Data.ChannelWithModel", b => { + b.HasOne("TelegramSearchBot.Model.Data.LLMApiBinding", "ApiBinding") + .WithMany() + .HasForeignKey("ApiBindingId"); + b.HasOne("TelegramSearchBot.Model.Data.LLMChannel", "LLMChannel") .WithMany("Models") .HasForeignKey("LLMChannelId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.Navigation("ApiBinding"); + b.Navigation("LLMChannel"); }); @@ -827,6 +872,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Message"); }); + modelBuilder.Entity("TelegramSearchBot.Model.Data.LLMApiBinding", b => + { + b.HasOne("TelegramSearchBot.Model.Data.LLMChannel", "LLMChannel") + .WithMany("Bindings") + .HasForeignKey("LLMChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LLMChannel"); + }); + modelBuilder.Entity("TelegramSearchBot.Model.Data.MessageExtension", b => { b.HasOne("TelegramSearchBot.Model.Data.Message", "Message") @@ -877,6 +933,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("TelegramSearchBot.Model.Data.LLMChannel", b => { + b.Navigation("Bindings"); + b.Navigation("Models"); }); diff --git a/TelegramSearchBot.Database/Model/Data/ChannelWithModel.cs b/TelegramSearchBot.Database/Model/Data/ChannelWithModel.cs index 625357cd..40ca71a0 100644 --- a/TelegramSearchBot.Database/Model/Data/ChannelWithModel.cs +++ b/TelegramSearchBot.Database/Model/Data/ChannelWithModel.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using TelegramSearchBot.Model.AI; namespace TelegramSearchBot.Model.Data { public class ChannelWithModel { @@ -20,6 +21,23 @@ public class ChannelWithModel { /// public bool IsDeleted { get; set; } = false; + /// + /// 关联的 API 绑定(nullable:旧二进制写入的 legacy 行没有绑定,运行时按 channel 默认 binding 解释) + /// + [ForeignKey("ApiBinding")] + public int? ApiBindingId { get; set; } + public virtual LLMApiBinding ApiBinding { get; set; } + + /// + /// 授权来源:Manual=管理员手工添加(不被刷新软删);Discovered=来自授权快照 + /// + public AuthorizationSource AuthorizationSource { get; set; } = AuthorizationSource.Manual; + + /// + /// 模型级协议覆盖:true 时该模型优先使用此 binding,覆盖 channel 默认绑定 + /// + public bool IsPreferred { get; set; } = false; + /// /// 关联的模型能力信息 /// diff --git a/TelegramSearchBot.Database/Model/Data/LLMApiBinding.cs b/TelegramSearchBot.Database/Model/Data/LLMApiBinding.cs new file mode 100644 index 00000000..e2387da5 --- /dev/null +++ b/TelegramSearchBot.Database/Model/Data/LLMApiBinding.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using TelegramSearchBot.Model.AI; + +namespace TelegramSearchBot.Model.Data { + /// + /// 一个 channel(品牌/订阅账号)下的一条 API 绑定:endpoint + 协议 + 认证方式。 + /// 每 channel 至多一条 IsDefault=true;secret 仍共享自 LLMChannel.ApiKey。 + /// + public class LLMApiBinding { + [Key] + public int Id { get; set; } + + [ForeignKey("LLMChannel")] + public int LLMChannelId { get; set; } + public virtual LLMChannel LLMChannel { get; set; } + + public string Endpoint { get; set; } + public LlmProtocol Protocol { get; set; } + public LlmAuthProfile AuthProfile { get; set; } + public bool IsDefault { get; set; } + } +} diff --git a/TelegramSearchBot.Database/Model/Data/LLMChannel.cs b/TelegramSearchBot.Database/Model/Data/LLMChannel.cs index bdd83f4c..22b9d26d 100644 --- a/TelegramSearchBot.Database/Model/Data/LLMChannel.cs +++ b/TelegramSearchBot.Database/Model/Data/LLMChannel.cs @@ -24,5 +24,10 @@ public class LLMChannel { public int Priority { get; set; } public virtual ICollection Models { get; set; } + + /// + /// 该 channel 的 API 绑定(endpoint/协议/认证)。至多一条 IsDefault=true。 + /// + public virtual ICollection Bindings { get; set; } = new List(); } } diff --git a/TelegramSearchBot.Database/Model/DataDbContext.cs b/TelegramSearchBot.Database/Model/DataDbContext.cs index 40703c84..53aebc4f 100644 --- a/TelegramSearchBot.Database/Model/DataDbContext.cs +++ b/TelegramSearchBot.Database/Model/DataDbContext.cs @@ -78,6 +78,20 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { .HasForeignKey(ti => ti.TodoListId) .OnDelete(DeleteBehavior.Cascade); + // 配置 API 绑定:channel 一对多 bindings;模型可选关联 binding + modelBuilder.Entity() + .HasOne(b => b.LLMChannel) + .WithMany(c => c.Bindings) + .HasForeignKey(b => b.LLMChannelId); + + modelBuilder.Entity() + .HasIndex(b => b.LLMChannelId); + + modelBuilder.Entity() + .HasOne(m => m.ApiBinding) + .WithMany() + .HasForeignKey(m => m.ApiBindingId); + // You can add other configurations here if needed } public virtual DbSet Messages { get; set; } @@ -87,6 +101,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { public virtual DbSet GroupSettings { get; set; } public virtual DbSet LLMChannels { get; set; } public virtual DbSet ChannelsWithModel { get; set; } + public virtual DbSet LLMApiBindings { get; set; } public virtual DbSet ModelCapabilities { get; set; } public virtual DbSet AppConfigurationItems { get; set; } // Added for BiliCookie and other app configs public virtual DbSet ShortUrlMappings { get; set; } = null!; diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs index 039d04ae..608d9d21 100644 --- a/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs @@ -242,5 +242,325 @@ public async Task AnalyzeImageAsync_WithCustomPrompt_ForwardsPromptToProvider() Assert.Single(results); Assert.Equal("recognized text", results[0]); } + + // ==================================================================== + // Phase 2:确定性路由解析(LlmRouteResolver,General 与 Agent 路径共用) + // ==================================================================== + + private static LLMChannel CreateChannel(int id, string gateway, LLMProvider provider, int priority, int parallel = 1) { + return new LLMChannel { + Id = id, + Name = $"ch{id}", + Gateway = gateway, + ApiKey = "channel-key", + Provider = provider, + Priority = priority, + Parallel = parallel + }; + } + + private static LLMApiBinding CreateBinding(int id, int channelId, string endpoint, LlmProtocol protocol, LlmAuthProfile auth, bool isDefault) { + return new LLMApiBinding { + Id = id, + LLMChannelId = channelId, + Endpoint = endpoint, + Protocol = protocol, + AuthProfile = auth, + IsDefault = isDefault + }; + } + + [Fact] + public void Resolve_SameModelTwoBindings_DefaultBindingPicked() { + var channel = CreateChannel(1, "https://legacy", LLMProvider.OpenAI, 10); + var bDefault = CreateBinding(1, 1, "https://zen/v1", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer, isDefault: true); + var bOther = CreateBinding(2, 1, "https://zen/go/v1", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer, isDefault: false); + channel.Bindings.Add(bDefault); + channel.Bindings.Add(bOther); + var rows = new List { + new() { Id = 1, ModelName = "m", LLMChannelId = 1, ApiBindingId = bDefault.Id, ApiBinding = bDefault }, + new() { Id = 2, ModelName = "m", LLMChannelId = 1, ApiBindingId = bOther.Id, ApiBinding = bOther } + }; + + var route = LlmRouteResolver.Resolve(channel, "m", rows, _loggerMock.Object); + + Assert.NotNull(route); + Assert.Equal(bDefault.Id, route!.Binding!.Id); + Assert.False(route.IsLegacyFallback); + } + + [Fact] + public void Resolve_IsPreferred_OverridesChannelDefault() { + var channel = CreateChannel(1, "https://legacy", LLMProvider.OpenAI, 10); + var bDefault = CreateBinding(1, 1, "https://zen/v1", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer, isDefault: true); + var bPreferred = CreateBinding(2, 1, "https://zen/go/v1", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer, isDefault: false); + channel.Bindings.Add(bDefault); + channel.Bindings.Add(bPreferred); + var rows = new List { + new() { Id = 1, ModelName = "m", LLMChannelId = 1, ApiBindingId = bDefault.Id, ApiBinding = bDefault }, + new() { Id = 2, ModelName = "m", LLMChannelId = 1, ApiBindingId = bPreferred.Id, ApiBinding = bPreferred, IsPreferred = true } + }; + + var route = LlmRouteResolver.Resolve(channel, "m", rows, _loggerMock.Object); + + Assert.NotNull(route); + Assert.Equal(bPreferred.Id, route!.Binding!.Id); + Assert.Equal(LlmProtocol.OpenAIResponses, route.Binding.Protocol); + } + + [Fact] + public void Resolve_TwoIsDefaultBindings_StableOrderByBindingIdAndWarns() { + var channel = CreateChannel(1, "https://legacy", LLMProvider.OpenAI, 10); + var b1 = CreateBinding(1, 1, "https://zen/v1", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer, isDefault: true); + var b2 = CreateBinding(2, 1, "https://zen/go/v1", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer, isDefault: true); + channel.Bindings.Add(b1); + channel.Bindings.Add(b2); + var rows = new List { + new() { Id = 1, ModelName = "m", LLMChannelId = 1, ApiBindingId = b1.Id, ApiBinding = b1 }, + new() { Id = 2, ModelName = "m", LLMChannelId = 1, ApiBindingId = b2.Id, ApiBinding = b2 } + }; + + var route = LlmRouteResolver.Resolve(channel, "m", rows, _loggerMock.Object); + + Assert.NotNull(route); + Assert.Equal(b1.Id, route!.Binding!.Id); // 稳定排序:最小 binding.Id 胜出,不 throw + AssertLogWarningContains("IsDefault"); + } + + [Fact] + public void Resolve_TwoIsPreferredRows_StableOrderAndWarns() { + var channel = CreateChannel(1, "https://legacy", LLMProvider.OpenAI, 10); + var b1 = CreateBinding(1, 1, "https://zen/v1", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer, isDefault: true); + var b2 = CreateBinding(2, 1, "https://zen/go/v1", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer, isDefault: false); + channel.Bindings.Add(b1); + channel.Bindings.Add(b2); + var rows = new List { + new() { Id = 1, ModelName = "m", LLMChannelId = 1, ApiBindingId = b1.Id, ApiBinding = b1, IsPreferred = true }, + new() { Id = 2, ModelName = "m", LLMChannelId = 1, ApiBindingId = b2.Id, ApiBinding = b2, IsPreferred = true } + }; + + var route = LlmRouteResolver.Resolve(channel, "m", rows, _loggerMock.Object); + + Assert.NotNull(route); + Assert.Equal(b1.Id, route!.Binding!.Id); // 稳定排序:最小 binding.Id 胜出,不 throw + AssertLogWarningContains("IsPreferred"); + } + + [Fact] + public void Resolve_NullApiBindingId_InterpretsAsChannelDefault() { + var channel = CreateChannel(1, "https://legacy", LLMProvider.OpenAI, 10); + var bDefault = CreateBinding(1, 1, "https://zen/v1", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer, isDefault: true); + channel.Bindings.Add(bDefault); + // 旧二进制写入的 legacy 行:ApiBindingId == null → 解释为渠道默认 binding + var rows = new List { + new() { Id = 1, ModelName = "m", LLMChannelId = 1, ApiBindingId = null } + }; + + var route = LlmRouteResolver.Resolve(channel, "m", rows, _loggerMock.Object); + + Assert.NotNull(route); + Assert.Equal(bDefault.Id, route!.Binding!.Id); + Assert.Equal("https://zen/v1", route.Binding.Endpoint); + Assert.False(route.IsLegacyFallback); + } + + [Fact] + public void Resolve_NoDefaultBinding_LegacyFallbackAndWarns() { + var channel = CreateChannel(1, "https://legacy", LLMProvider.Anthropic, 10); + var rows = new List { + new() { Id = 1, ModelName = "m", LLMChannelId = 1, ApiBindingId = null } + }; + + var route = LlmRouteResolver.Resolve(channel, "m", rows, _loggerMock.Object); + + Assert.NotNull(route); + Assert.Null(route!.Binding); + Assert.True(route.IsLegacyFallback); + Assert.Equal(LLMProvider.Anthropic, route.Channel.Provider); // 回退 legacy Provider/Gateway + AssertLogWarningContains("临时回退"); + } + + [Fact] + public void ResolveFirst_ChannelPriorityDesc_AcrossChannels() { + var lowChannel = CreateChannel(1, "https://low", LLMProvider.OpenAI, priority: 1); + var bLow = CreateBinding(1, 1, "https://low/v1", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer, isDefault: true); + lowChannel.Bindings.Add(bLow); + var highChannel = CreateChannel(2, "https://high", LLMProvider.OpenAI, priority: 10); + var bHigh = CreateBinding(2, 2, "https://high/v1", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer, isDefault: true); + highChannel.Bindings.Add(bHigh); + + var candidates = new List<(LLMChannel, List)> { + (highChannel, new List { new() { Id = 1, ModelName = "m", LLMChannelId = 2, ApiBindingId = bHigh.Id, ApiBinding = bHigh } }), + (lowChannel, new List { new() { Id = 2, ModelName = "m", LLMChannelId = 1, ApiBindingId = bLow.Id, ApiBinding = bLow } }) + }; + + var route = LlmRouteResolver.ResolveFirst(candidates, "m", _loggerMock.Object); + + Assert.NotNull(route); + Assert.Equal(highChannel.Id, route!.Channel.Id); + Assert.Equal(bHigh.Id, route.Binding!.Id); + } + + [Fact] + public void LlmBindingSupport_Endpoint_FromBindingWhenPresent() { + var channel = CreateChannel(1, "https://legacy", LLMProvider.OpenAI, 10); + var binding = CreateBinding(1, 1, "https://zen/v1", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer, isDefault: true); + + Assert.Equal("https://zen/v1", LlmBindingSupport.ResolveEndpoint(channel, binding)); + Assert.Equal("https://legacy", LlmBindingSupport.ResolveEndpoint(channel, null)); + } + + [Fact] + public void LlmBindingSupport_AuthProfile_Isolation() { + var channel = CreateChannel(1, "https://legacy", LLMProvider.OpenAI, 10); + channel.ApiKey = "shared-secret"; + + // Bearer:OpenAI SDK 原生发 Authorization: Bearer ;AnthropicApiKey:Anthropic SDK 原生发 x-api-key 。 + // 两者都从 channel 取共享 key;None 走无 key 路径(keyless)。 + var bearer = CreateBinding(1, 1, "https://zen/v1", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer, isDefault: true); + var anthropic = CreateBinding(2, 1, "https://zen/v1", LlmProtocol.AnthropicMessages, LlmAuthProfile.AnthropicApiKey, isDefault: false); + var none = CreateBinding(3, 1, "http://localhost:11434", LlmProtocol.Ollama, LlmAuthProfile.None, isDefault: false); + + Assert.Equal("shared-secret", LlmBindingSupport.ResolveApiKey(channel, bearer)); + Assert.Equal("shared-secret", LlmBindingSupport.ResolveApiKey(channel, anthropic)); + Assert.Equal(string.Empty, LlmBindingSupport.ResolveApiKey(channel, none)); + // 无 binding(legacy)时保持旧行为:取 channel key + Assert.Equal("shared-secret", LlmBindingSupport.ResolveApiKey(channel, null)); + } + + [Fact] + public async Task ExecOperationAsync_UsesResolvedRouteBinding_AndPassesBindingToService() { + var channel = CreateChannel(11, "https://legacy", LLMProvider.OpenAI, priority: 10); + channel.ApiKey = "shared-secret"; + var bDefault = CreateBinding(21, 11, "https://zen/v1", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer, isDefault: true); + channel.Bindings.Add(bDefault); + _dbContext.LLMChannels.Add(channel); + _dbContext.LLMApiBindings.Add(bDefault); + _dbContext.ChannelsWithModel.Add(new ChannelWithModel { + Id = 31, + ModelName = "m", + LLMChannelId = 11, + ApiBindingId = bDefault.Id, + ApiBinding = bDefault + }); + await _dbContext.SaveChangesAsync(); + + var serviceMock = new Mock(); + serviceMock.Setup(s => s.IsHealthyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + serviceMock.Setup(s => s.ExecAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(EmptyStringStream()); + _factoryMock.Setup(f => f.GetLLMService(It.IsAny())).Returns(serviceMock.Object); + + var results = new List(); + var message = new TelegramSearchBot.Model.Data.Message { Content = "hi", GroupId = 123, MessageId = 1, FromUserId = 1 }; + await foreach (var r in _service.ExecOperationAsync( + (svc, ch, b, ct) => svc.ExecAsync(message, 123, "m", ch, b, new LlmExecutionContext(), ct), + "m")) { + results.Add(r); + } + + _factoryMock.Verify(f => f.GetLLMService(It.Is(r => r.Channel.Id == 11 && r.Binding != null && r.Binding.Id == bDefault.Id)), Times.Once); + serviceMock.Verify(s => s.ExecAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.Is(b => b.Id == bDefault.Id), It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ExecOperationAsync_ChannelPriorityDesc_Respected() { + var lowChannel = CreateChannel(12, "https://low", LLMProvider.OpenAI, priority: 1); + var bLow = CreateBinding(22, 12, "https://low/v1", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer, isDefault: true); + lowChannel.Bindings.Add(bLow); + var highChannel = CreateChannel(13, "https://high", LLMProvider.OpenAI, priority: 10); + var bHigh = CreateBinding(23, 13, "https://high/v1", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer, isDefault: true); + highChannel.Bindings.Add(bHigh); + _dbContext.LLMChannels.AddRange(lowChannel, highChannel); + _dbContext.LLMApiBindings.AddRange(bLow, bHigh); + _dbContext.ChannelsWithModel.AddRange( + new ChannelWithModel { Id = 32, ModelName = "m", LLMChannelId = 12, ApiBindingId = bLow.Id, ApiBinding = bLow }, + new ChannelWithModel { Id = 33, ModelName = "m", LLMChannelId = 13, ApiBindingId = bHigh.Id, ApiBinding = bHigh }); + await _dbContext.SaveChangesAsync(); + + var serviceMock = new Mock(); + serviceMock.Setup(s => s.IsHealthyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + serviceMock.Setup(s => s.ExecAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(EmptyStringStream()); + _factoryMock.Setup(f => f.GetLLMService(It.IsAny())).Returns(serviceMock.Object); + + var message = new TelegramSearchBot.Model.Data.Message { Content = "hi", GroupId = 123, MessageId = 1, FromUserId = 1 }; + await foreach (var _ in _service.ExecOperationAsync( + (svc, ch, b, ct) => svc.ExecAsync(message, 123, "m", ch, b, new LlmExecutionContext(), ct), + "m")) { + } + + // 高优先级渠道先被选中;成功后低优先级渠道不再被访问 + _factoryMock.Verify(f => f.GetLLMService(It.Is(r => r.Channel.Id == highChannel.Id && r.Binding!.Id == bHigh.Id)), Times.Once); + _factoryMock.Verify(f => f.GetLLMService(It.Is(r => r.Channel.Id == lowChannel.Id)), Times.Never); + } + + [Fact] + public async Task ResumeFromSnapshotAsync_ResolvesRouteAndPassesBinding() { + var channel = CreateChannel(14, "https://legacy", LLMProvider.OpenAI, priority: 10); + channel.ApiKey = "shared-secret"; + var bDefault = CreateBinding(24, 14, "https://zen/v1", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer, isDefault: true); + channel.Bindings.Add(bDefault); + _dbContext.LLMChannels.Add(channel); + _dbContext.LLMApiBindings.Add(bDefault); + _dbContext.ChannelsWithModel.Add(new ChannelWithModel { + Id = 34, + ModelName = "m", + LLMChannelId = 14, + ApiBindingId = bDefault.Id, + ApiBinding = bDefault + }); + await _dbContext.SaveChangesAsync(); + + var serviceMock = new Mock(); + serviceMock.Setup(s => s.ResumeFromSnapshotAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(EmptyStringStream()); + _factoryMock.Setup(f => f.GetLLMService(It.IsAny())).Returns(serviceMock.Object); + + var snapshot = new LlmContinuationSnapshot { + SnapshotId = "s1", + ChannelId = 14, + ModelName = "m", + Provider = "OpenAI", + ChatId = 123, + UserId = 1, + OriginalMessageId = 1 + }; + var results = new List(); + await foreach (var r in _service.ResumeFromSnapshotAsync(snapshot, new LlmExecutionContext())) { + results.Add(r); + } + + _factoryMock.Verify(f => f.GetLLMService(It.Is(r => r.Channel.Id == 14 && r.Binding != null && r.Binding.Id == bDefault.Id)), Times.Once); + serviceMock.Verify(s => s.ResumeFromSnapshotAsync( + It.IsAny(), It.IsAny(), + It.Is(b => b.Id == bDefault.Id), It.IsAny(), It.IsAny()), + Times.Once); + } + + private static async IAsyncEnumerable EmptyStringStream() { + yield break; + } + + private void AssertLogWarningContains(string fragment) { + _loggerMock.Verify(l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v!.ToString()!.Contains(fragment)), + It.IsAny(), + It.IsAny>()), + Times.AtLeastOnce); + } } } diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/LLMFactoryTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/LLMFactoryTests.cs index 6f4b47f1..2f258ea0 100644 --- a/TelegramSearchBot.LLM.Test/Service/AI/LLM/LLMFactoryTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/LLMFactoryTests.cs @@ -102,6 +102,152 @@ public void GetLLMService_None_ThrowsKeyNotFound() { Assert.Throws(() => _factory.GetLLMService(LLMProvider.None)); } + // ==================================================================== + // Phase 2:按 binding 协议选择 client(LlmProtocol) + // ==================================================================== + + [Fact] + public void GetLLMService_Protocol_OpenAIChat_ReturnsOpenAIService() { + var service = _factory.GetLLMService(LlmProtocol.OpenAIChat); + Assert.NotNull(service); + Assert.IsAssignableFrom(service); + } + + [Fact] + public void GetLLMService_Protocol_OpenAIResponses_ReturnsOpenAIResponsesService() { + var service = _factory.GetLLMService(LlmProtocol.OpenAIResponses); + Assert.NotNull(service); + Assert.IsAssignableFrom(service); + } + + [Fact] + public void GetLLMService_Protocol_AnthropicMessages_ReturnsAnthropicService() { + var service = _factory.GetLLMService(LlmProtocol.AnthropicMessages); + Assert.NotNull(service); + Assert.IsAssignableFrom(service); + } + + [Fact] + public void GetLLMService_Protocol_Ollama_ReturnsOllamaService() { + var service = _factory.GetLLMService(LlmProtocol.Ollama); + Assert.NotNull(service); + Assert.IsAssignableFrom(service); + } + + [Fact] + public void GetLLMService_Protocol_Gemini_ReturnsGeminiService() { + var service = _factory.GetLLMService(LlmProtocol.Gemini); + Assert.NotNull(service); + Assert.IsAssignableFrom(service); + } + + [Fact] + public void GetLLMService_Route_BindingNull_FallsBackToProvider() { + var channel = new LLMChannel { + Name = "legacy", + Gateway = "https://legacy.example", + ApiKey = "k", + Provider = LLMProvider.Anthropic, + Parallel = 1, + Priority = 1 + }; + var route = new ResolvedLlmRoute(channel, null, new ChannelWithModel { ModelName = "m" }); + var service = _factory.GetLLMService(route); + Assert.IsAssignableFrom(service); + } + + [Fact] + public void GetLLMService_Route_BindingNonNull_UsesBindingProtocol() { + var channel = new LLMChannel { + Name = "oc", + Gateway = "https://legacy.example", + ApiKey = "k", + Provider = LLMProvider.Anthropic, + Parallel = 1, + Priority = 1 + }; + var binding = new LLMApiBinding { + Id = 7, + LLMChannelId = channel.Id, + Endpoint = "https://opencode.ai/zen/v1", + Protocol = LlmProtocol.OpenAIChat, + AuthProfile = LlmAuthProfile.Bearer, + IsDefault = true + }; + var route = new ResolvedLlmRoute(channel, binding, new ChannelWithModel { ModelName = "m" }); + // 渠道 provider 是 Anthropic,但 binding 协议是 OpenAIChat → 必须选 OpenAIService(绝不按品牌猜) + var service = _factory.GetLLMService(route); + Assert.IsAssignableFrom(service); + } + + [Fact] + public void GeneralAndAgentPaths_ResolveSameRoute_AndSelectSameClient() { + // 同一模型、同一 channel、双 binding:General 路径(Resolve)与 Agent 路径(ResolveFirst) + // 必须解析出相同的 binding(IsPreferred 优先于 channel 默认),且 factory 选同一 client。 + var channel = new LLMChannel { + Name = "oc", + Gateway = "https://legacy.example", + ApiKey = "shared-secret", + Provider = LLMProvider.OpenAI, + Parallel = 2, + Priority = 10 + }; + var chatBinding = new LLMApiBinding { + Id = 1, + LLMChannelId = channel.Id, + Endpoint = "https://opencode.ai/zen/v1", + Protocol = LlmProtocol.OpenAIChat, + AuthProfile = LlmAuthProfile.Bearer, + IsDefault = true + }; + var responsesBinding = new LLMApiBinding { + Id = 2, + LLMChannelId = channel.Id, + Endpoint = "https://opencode.ai/zen/go/v1", + Protocol = LlmProtocol.OpenAIResponses, + AuthProfile = LlmAuthProfile.Bearer, + IsDefault = false + }; + channel.Bindings.Add(chatBinding); + channel.Bindings.Add(responsesBinding); + var chatRow = new ChannelWithModel { + Id = 1, + ModelName = "gpt-x", + LLMChannelId = channel.Id, + LLMChannel = channel, + ApiBindingId = chatBinding.Id, + ApiBinding = chatBinding + }; + var preferredRow = new ChannelWithModel { + Id = 2, + ModelName = "gpt-x", + LLMChannelId = channel.Id, + LLMChannel = channel, + ApiBindingId = responsesBinding.Id, + ApiBinding = responsesBinding, + IsPreferred = true + }; + var rows = new List { chatRow, preferredRow }; + + var generalRoute = LlmRouteResolver.Resolve(channel, "gpt-x", rows, _loggerMock.Object); + var agentRoute = LlmRouteResolver.ResolveFirst( + new[] { (channel, rows) }, "gpt-x", _loggerMock.Object); + + Assert.NotNull(generalRoute); + Assert.NotNull(agentRoute); + Assert.Equal(generalRoute!.Binding!.Id, agentRoute!.Binding!.Id); + Assert.Equal(responsesBinding.Id, generalRoute.Binding.Id); + Assert.Equal(responsesBinding.Endpoint, generalRoute.Binding.Endpoint); + Assert.Equal(responsesBinding.Protocol, generalRoute.Binding.Protocol); + Assert.Equal(responsesBinding.AuthProfile, generalRoute.Binding.AuthProfile); + Assert.Equal("gpt-x", generalRoute.Model.ModelName); + + var generalService = _factory.GetLLMService(generalRoute); + var agentService = _factory.GetLLMService(agentRoute); + Assert.Same(generalService, agentService); + Assert.IsAssignableFrom(generalService); + } + [Fact] public void ServiceName_ReturnsLLMFactory() { Assert.Equal("LLMFactory", _factory.ServiceName); diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs index 9ff9df53..7397fca3 100644 --- a/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs @@ -20,6 +20,7 @@ public class ModelCapabilityServiceTests { private readonly DataDbContext _dbContext; private readonly Mock> _loggerMock; private readonly Mock _serviceProviderMock; + private readonly Mock _openAIServiceMock; private readonly ModelCapabilityService _service; public ModelCapabilityServiceTests() { @@ -30,6 +31,15 @@ public ModelCapabilityServiceTests() { _loggerMock = new Mock>(); _serviceProviderMock = new Mock(); + var messageExtensionServiceMock = new Mock(); + _openAIServiceMock = new Mock( + _dbContext, + new Mock>().Object, + messageExtensionServiceMock.Object, + new Mock().Object); + _serviceProviderMock.Setup(sp => sp.GetService(typeof(OpenAIService))) + .Returns(_openAIServiceMock.Object); + _service = new ModelCapabilityService( _loggerMock.Object, _dbContext, @@ -407,5 +417,111 @@ public async Task GetModelsByCapability_NoMatches_ReturnsEmpty() { var result = await _service.GetModelsByCapability("nonexistent"); Assert.Empty(result); } + + // ===== Phase 3: metadata 不创建/不复活授权行(blueprint §四.6) ===== + + [Fact] + public async Task UpdateChannelModelCapabilities_MetadataDoesNotCreateRow() { + // Arrange: 渠道存在,但没有任何模型行;metadata 返回 phantom-model + var channel = new LLMChannel { + Name = "openai", + Gateway = "gw", + ApiKey = "key", + Provider = LLMProvider.OpenAI, + Parallel = 1, + Priority = 1 + }; + _dbContext.LLMChannels.Add(channel); + await _dbContext.SaveChangesAsync(); + + _openAIServiceMock.Setup(s => s.GetAllModelsWithCapabilities(It.IsAny())) + .ReturnsAsync(new List { + new ModelWithCapabilities { ModelName = "phantom-model" } + }); + + // Act + var result = await _service.UpdateChannelModelCapabilities(channel.Id); + + // Assert: 不创建新行、不产生能力记录 + Assert.True(result); + Assert.Empty(await _dbContext.ChannelsWithModel.ToListAsync()); + Assert.Empty(await _dbContext.ModelCapabilities.ToListAsync()); + } + + [Fact] + public async Task UpdateChannelModelCapabilities_MetadataDoesNotResurrect() { + // Arrange: 已软删除的模型行,metadata 返回同名模型 + var channel = new LLMChannel { + Name = "openai", + Gateway = "gw", + ApiKey = "key", + Provider = LLMProvider.OpenAI, + Parallel = 1, + Priority = 1 + }; + _dbContext.LLMChannels.Add(channel); + await _dbContext.SaveChangesAsync(); + + var cwm = new ChannelWithModel { + ModelName = "gpt-4o", + LLMChannelId = channel.Id, + IsDeleted = true + }; + _dbContext.ChannelsWithModel.Add(cwm); + await _dbContext.SaveChangesAsync(); + + var modelWithCaps = new ModelWithCapabilities { ModelName = "gpt-4o" }; + modelWithCaps.SetCapability("function_calling", "true"); + _openAIServiceMock.Setup(s => s.GetAllModelsWithCapabilities(It.IsAny())) + .ReturnsAsync(new List { modelWithCaps }); + + // Act + var result = await _service.UpdateChannelModelCapabilities(channel.Id); + + // Assert: 行保持已删除,且未写入能力 + Assert.True(result); + var loaded = await _dbContext.ChannelsWithModel.SingleAsync(); + Assert.True(loaded.IsDeleted); + Assert.Empty(await _dbContext.ModelCapabilities.ToListAsync()); + } + + [Fact] + public async Task UpdateChannelModelCapabilities_MetadataMergesCaseInsensitive() { + // Arrange: 已存在的非删除行 gpt-4o;metadata 以 GPT-4O 返回 → 忽略大小写合并 + var channel = new LLMChannel { + Name = "openai", + Gateway = "gw", + ApiKey = "key", + Provider = LLMProvider.OpenAI, + Parallel = 1, + Priority = 1 + }; + _dbContext.LLMChannels.Add(channel); + await _dbContext.SaveChangesAsync(); + + var cwm = new ChannelWithModel { + ModelName = "gpt-4o", + LLMChannelId = channel.Id, + IsDeleted = false + }; + _dbContext.ChannelsWithModel.Add(cwm); + await _dbContext.SaveChangesAsync(); + + var modelWithCaps = new ModelWithCapabilities { ModelName = "GPT-4O" }; + modelWithCaps.SetCapability("function_calling", "true"); + _openAIServiceMock.Setup(s => s.GetAllModelsWithCapabilities(It.IsAny())) + .ReturnsAsync(new List { modelWithCaps }); + + // Act + var result = await _service.UpdateChannelModelCapabilities(channel.Id); + + // Assert: 能力合并到现有行 + Assert.True(result); + var caps = await _dbContext.ModelCapabilities.ToListAsync(); + Assert.Single(caps); + Assert.Equal(cwm.Id, caps[0].ChannelWithModelId); + Assert.Equal("function_calling", caps[0].CapabilityName); + Assert.Equal("true", caps[0].CapabilityValue); + } } } diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/OpenCodeWireProtocolTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/OpenCodeWireProtocolTests.cs new file mode 100644 index 00000000..677d1d9e --- /dev/null +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/OpenCodeWireProtocolTests.cs @@ -0,0 +1,688 @@ +#pragma warning disable CS8602 // Dereference of a possibly null reference +// Phase 4 (blueprint §八-阶段4): protocol-level fake-server tests for OpenCode Go/Zen. +// Drives the REAL SDK-backed clients (OpenAIService=Chat, OpenAIResponsesService=Responses, +// AnthropicService=Messages) against a loopback HTTP server that mimics the Go/Zen URL +// spaces (/zen/v1/* and /zen/go/v1/*) and asserts the wire shape per protocol: +// path, auth header isolation, body shape, tool round-trip, SSE stream events, system/instructions. +// No new packages: the server is a hand-rolled TcpListener (loopback only, precedent: +// TelegramSearchBot.Test/AppBootstrap/GarnetLuaScriptIntegrationTests.cs). +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Newtonsoft.Json.Linq; +using TelegramSearchBot.Attributes; +using TelegramSearchBot.Interface; +using TelegramSearchBot.Interface.AI.LLM; +using TelegramSearchBot.Model; +using TelegramSearchBot.Model.AI; +using TelegramSearchBot.Model.Data; +using TelegramSearchBot.Service.AI.LLM; +using Xunit; + +namespace TelegramSearchBot.Test.Service.AI.LLM { + + /// Fake tool used for the tool round-trip wire assertions. Static => no DI needed. + public static class FakeEchoToolService { + [BuiltInTool("Echo the given text back.", Name = "fake_echo_tool")] + public static string FakeEcho([BuiltInParameter("text to echo")] string text) { + return $"echo:{text}"; + } + } + + /// A captured loopback HTTP request. + public sealed class CapturedRequest { + public string Method { get; set; } + public string Path { get; set; } + public Dictionary Headers { get; } = new(StringComparer.OrdinalIgnoreCase); + public string Body { get; set; } + public JObject Json => JObject.Parse(Body); + public string Header(string name) => Headers.TryGetValue(name, out var v) ? v : null; + public bool HasHeader(string name) => Headers.ContainsKey(name); + } + + /// Minimal HTTP/1.1 loopback server. One queued response per request, in order. + public sealed class FakeWireServer : IDisposable { + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _acceptLoop; + private readonly ConcurrentQueue _responses = new(); + + public int Port { get; } + public string Origin => $"http://127.0.0.1:{Port}"; + public List Requests { get; } = new(); + public bool WasDisposed { get; private set; } + + public sealed record HttpResponse(int Status, string ContentType, string Body) { + public static HttpResponse Json(int status, string body) => new(status, "application/json", body); + public static HttpResponse Sse(params string[] events) => new(200, "text/event-stream", string.Join("\n\n", events) + "\n\n"); + public static HttpResponse Text(string body) => new(200, "text/plain", body); + } + + public FakeWireServer() { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _acceptLoop = Task.Run(AcceptLoopAsync); + } + + public void Enqueue(HttpResponse response) => _responses.Enqueue(response); + public void Enqueue(params HttpResponse[] responses) { + foreach (var r in responses) _responses.Enqueue(r); + } + + public string BaseUrl(string pathPrefix) => $"{Origin}{pathPrefix}"; + + private async Task AcceptLoopAsync() { + while (!_cts.IsCancellationRequested) { + TcpClient client; + try { + client = await _listener.AcceptTcpClientAsync(_cts.Token); + } catch { + return; + } + _ = HandleClientAsync(client); + } + } + + private async Task HandleClientAsync(TcpClient client) { + using (client) { + try { + client.ReceiveTimeout = 10000; + client.SendTimeout = 10000; + using var stream = client.GetStream(); + var request = await ReadRequestAsync(stream); + if (request == null) return; + lock (Requests) Requests.Add(request); + if (!_responses.TryDequeue(out var response)) { + response = HttpResponse.Json(500, "{\"error\":\"no scripted response\"}"); + } + await WriteResponseAsync(stream, response); + } catch { + // Client may abort mid-read (e.g. test teardown). Ignore. + } + } + } + + private static async Task ReadRequestAsync(Stream stream) { + // Read until end of headers + var headerBytes = new List(); + var buffer = new byte[1]; + int match = 0; + while (match < 4) { // \r\n\r\n + int n = await stream.ReadAsync(buffer.AsMemory(0, 1)); + if (n == 0) return null; + byte b = buffer[0]; + headerBytes.Add(b); + match = (b == (byte)"\r\n\r\n"[match]) ? match + 1 : (b == '\r' ? 1 : 0); + } + var headerText = Encoding.UTF8.GetString(headerBytes.ToArray()); + var lines = headerText.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + var requestLine = lines[0].Split(' '); + var request = new CapturedRequest { Method = requestLine[0], Path = requestLine[1] }; + foreach (var line in lines.Skip(1)) { + var idx = line.IndexOf(':'); + if (idx > 0) request.Headers[line.Substring(0, idx).Trim()] = line.Substring(idx + 1).Trim(); + } + + // Handle Expect: 100-continue + if (request.Header("Expect")?.Contains("100-continue", StringComparison.OrdinalIgnoreCase) == true) { + await stream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 100 Continue\r\n\r\n")); + } + + if (int.TryParse(request.Header("Content-Length"), out var length) && length > 0) { + var bodyBytes = new byte[length]; + int read = 0; + while (read < length) { + int n = await stream.ReadAsync(bodyBytes.AsMemory(read, length - read)); + if (n == 0) return null; + read += n; + } + request.Body = Encoding.UTF8.GetString(bodyBytes); + } + return request; + } + + private static async Task WriteResponseAsync(Stream stream, HttpResponse response) { + var bodyBytes = Encoding.UTF8.GetBytes(response.Body); + var head = $"HTTP/1.1 {response.Status} {(response.Status == 200 ? "OK" : "Error")}\r\n" + + $"Content-Type: {response.ContentType}\r\n" + + $"Content-Length: {bodyBytes.Length}\r\n" + + "Connection: close\r\n\r\n"; + await stream.WriteAsync(Encoding.ASCII.GetBytes(head)); + await stream.WriteAsync(bodyBytes); + await stream.FlushAsync(); + } + + public void Dispose() { + WasDisposed = true; + _cts.Cancel(); + try { _listener.Stop(); } catch { } + } + } + + /// + /// Wire-level protocol tests for OpenCode Go/Zen URL spaces. + /// Coverage matrix: Go×Chat, Go×Responses, Go×Messages, Zen×Chat, Zen×Responses, Zen×Messages. + /// + public class OpenCodeWireProtocolTests : IDisposable { + private const string ApiKey = "test-key-123"; + private const string ModelName = "test-model"; + + private readonly DataDbContext _db; + private readonly Mock> _openAILogger = new(); + private readonly Mock> _responsesLogger = new(); + private readonly Mock> _anthropicLogger = new(); + private readonly Mock _messageExtension = new(); + private readonly Mock _httpClientFactory = new(); + private readonly LLMChannel _channel; + private readonly Message _inputMessage; + + public OpenCodeWireProtocolTests() { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _db = new DataDbContext(options); + + _httpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(() => new HttpClient()); + + _channel = new LLMChannel { + Id = 1, + Name = "opencode", + Gateway = "http://unused.invalid", + ApiKey = ApiKey, + Provider = LLMProvider.OpenAI, + Parallel = 1, + Priority = 10 + }; + _db.LLMChannels.Add(_channel); + _db.ChannelsWithModel.Add(new ChannelWithModel { + Id = 1, ModelName = ModelName, LLMChannelId = 1, IsDeleted = false + }); + _db.UserData.Add(new UserData { Id = 1, FirstName = "Alice", LastName = "" }); + _db.UserData.Add(new UserData { Id = 2, FirstName = "Bot", LastName = "" }); + _db.Messages.Add(new Message { + Id = 1, GroupId = 100, MessageId = 1, FromUserId = 1, Content = "hello there", + DateTime = DateTime.UtcNow.AddMinutes(-2) + }); + _db.Messages.Add(new Message { + Id = 2, GroupId = 100, MessageId = 2, FromUserId = 2, Content = "hi", + DateTime = DateTime.UtcNow.AddMinutes(-1) + }); + _db.SaveChanges(); + + _inputMessage = new Message { + Content = "please respond", GroupId = 100, MessageId = 3, FromUserId = 1, + DateTime = DateTime.UtcNow + }; + + // Register tools once per process (static registry). Fake tool is static => no DI needed. + var sp = new ServiceCollection().BuildServiceProvider(); + McpToolHelper.EnsureInitialized( + typeof(OpenCodeWireProtocolTests).Assembly, + typeof(OpenAIService).Assembly, + sp, + new Mock().Object.CreateLogger("mcp")); + } + + public void Dispose() => _db.Dispose(); + + private LLMApiBinding Binding(string endpointPrefix) => new() { + Id = 1, + LLMChannelId = 1, + Endpoint = endpointPrefix, + Protocol = LlmProtocol.OpenAIChat, + AuthProfile = LlmAuthProfile.Bearer, + IsDefault = true + }; + + private static async Task> CollectAsync(IAsyncEnumerable stream) { + var results = new List(); + await foreach (var item in stream) results.Add(item); + return results; + } + + private async Task> RunChatAsync(string endpointPrefix) { + var service = new OpenAIService(_db, _openAILogger.Object, _messageExtension.Object, _httpClientFactory.Object); + return await CollectAsync(service.ExecAsync( + _inputMessage, 100, ModelName, _channel, Binding(endpointPrefix), + new LlmExecutionContext(), CancellationToken.None)); + } + + private async Task> RunResponsesAsync(string endpointPrefix) { + var service = new OpenAIResponsesService(_db, _responsesLogger.Object, _messageExtension.Object, _httpClientFactory.Object); + return await CollectAsync(service.ExecAsync( + _inputMessage, 100, ModelName, _channel, Binding(endpointPrefix), + new LlmExecutionContext(), CancellationToken.None)); + } + + private async Task> RunMessagesAsync(string endpointPrefix) { + var service = new AnthropicService(_db, _anthropicLogger.Object, _messageExtension.Object, _httpClientFactory.Object); + return await CollectAsync(service.ExecAsync( + _inputMessage, 100, ModelName, _channel, Binding(endpointPrefix), + new LlmExecutionContext(), CancellationToken.None)); + } + + // ==================================================================== + // Shared wire fixtures (SSE payloads) + // ==================================================================== + + private static string ChatChunk(string id, string deltaJson, string finishReason) => + "data: " + new JObject { + ["id"] = id, + ["object"] = "chat.completion.chunk", + ["created"] = 1720000000, + ["model"] = ModelName, + ["choices"] = new JArray(new JObject { + ["index"] = 0, + ["delta"] = deltaJson == null ? null : JObject.Parse(deltaJson), + ["finish_reason"] = finishReason + }) + }.ToString(Newtonsoft.Json.Formatting.None); + + private static readonly FakeWireServer.HttpResponse ChatTextSse = FakeWireServer.HttpResponse.Sse( + ChatChunk("chatcmpl-1", "{\"role\":\"assistant\",\"content\":\"\"}", null), + ChatChunk("chatcmpl-1", "{\"content\":\"Hello from the wire\"}", null), + ChatChunk("chatcmpl-1", "{}", "stop"), + "data: [DONE]"); + + private static readonly FakeWireServer.HttpResponse ChatToolCallSse = FakeWireServer.HttpResponse.Sse( + ChatChunk("chatcmpl-1", + "{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\"," + + "\"function\":{\"name\":\"fake_echo_tool\",\"arguments\":\"\"}}]}", null), + ChatChunk("chatcmpl-1", + "{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"text\\\":\\\"hi\\\"}\"}}]}", null), + ChatChunk("chatcmpl-1", "{}", "tool_calls"), + "data: [DONE]"); + + private static FakeWireServer.HttpResponse ResponsesTextSse() { + var msg = new JObject { + ["id"] = "msg_1", ["type"] = "message", ["role"] = "assistant", ["status"] = "in_progress", + ["content"] = new JArray() + }; + return FakeWireServer.HttpResponse.Sse( + ResponsesEvent("response.created", JObject.Parse("{\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":1720000000,\"status\":\"in_progress\",\"model\":\"" + ModelName + "\",\"output\":[],\"usage\":null}}")), + ResponsesEvent("response.output_item.added", new JObject { + ["type"] = "response.output_item.added", ["output_index"] = 0, ["item"] = msg + }), + ResponsesEvent("response.content_part.added", new JObject { + ["type"] = "response.content_part.added", ["item_id"] = "msg_1", ["output_index"] = 0, ["content_index"] = 0, + ["part"] = new JObject { ["type"] = "output_text", ["text"] = "", ["annotations"] = new JArray() } + }), + ResponsesEvent("response.output_text.delta", new JObject { + ["type"] = "response.output_text.delta", ["item_id"] = "msg_1", ["output_index"] = 0, ["content_index"] = 0, ["delta"] = "Hello from the wire" + }), + ResponsesEvent("response.output_text.done", new JObject { + ["type"] = "response.output_text.done", ["item_id"] = "msg_1", ["output_index"] = 0, ["content_index"] = 0, ["text"] = "Hello from the wire" + }), + ResponsesEvent("response.content_part.done", new JObject { + ["type"] = "response.content_part.done", ["item_id"] = "msg_1", ["output_index"] = 0, ["content_index"] = 0, + ["part"] = new JObject { ["type"] = "output_text", ["text"] = "Hello from the wire", ["annotations"] = new JArray() } + }), + ResponsesEvent("response.output_item.done", new JObject { + ["type"] = "response.output_item.done", ["output_index"] = 0, + ["item"] = new JObject { + ["id"] = "msg_1", ["type"] = "message", ["role"] = "assistant", ["status"] = "completed", + ["content"] = new JArray(new JObject { ["type"] = "output_text", ["text"] = "Hello from the wire", ["annotations"] = new JArray() }) + } + }), + ResponsesCompleted(new JObject { + ["id"] = "msg_1", ["type"] = "message", ["role"] = "assistant", ["status"] = "completed", + ["content"] = new JArray(new JObject { ["type"] = "output_text", ["text"] = "Hello from the wire", ["annotations"] = new JArray() }) + })); + } + + private static FakeWireServer.HttpResponse ResponsesFinalTextSse() { + var msg = new JObject { + ["id"] = "msg_2", ["type"] = "message", ["role"] = "assistant", ["status"] = "in_progress", + ["content"] = new JArray() + }; + return FakeWireServer.HttpResponse.Sse( + ResponsesEvent("response.created", JObject.Parse("{\"type\":\"response.created\",\"response\":{\"id\":\"resp_2\",\"object\":\"response\",\"created_at\":1720000000,\"status\":\"in_progress\",\"model\":\"" + ModelName + "\",\"output\":[],\"usage\":null}}")), + ResponsesEvent("response.output_item.added", new JObject { + ["type"] = "response.output_item.added", ["output_index"] = 0, ["item"] = msg + }), + ResponsesEvent("response.content_part.added", new JObject { + ["type"] = "response.content_part.added", ["item_id"] = "msg_2", ["output_index"] = 0, ["content_index"] = 0, + ["part"] = new JObject { ["type"] = "output_text", ["text"] = "", ["annotations"] = new JArray() } + }), + ResponsesEvent("response.output_text.delta", new JObject { + ["type"] = "response.output_text.delta", ["item_id"] = "msg_2", ["output_index"] = 0, ["content_index"] = 0, ["delta"] = "final answer from wire" + }), + ResponsesEvent("response.output_text.done", new JObject { + ["type"] = "response.output_text.done", ["item_id"] = "msg_2", ["output_index"] = 0, ["content_index"] = 0, ["text"] = "final answer from wire" + }), + ResponsesEvent("response.output_item.done", new JObject { + ["type"] = "response.output_item.done", ["output_index"] = 0, + ["item"] = new JObject { + ["id"] = "msg_2", ["type"] = "message", ["role"] = "assistant", ["status"] = "completed", + ["content"] = new JArray(new JObject { ["type"] = "output_text", ["text"] = "final answer from wire", ["annotations"] = new JArray() }) + } + }), + ResponsesCompleted(new JObject { + ["id"] = "msg_2", ["type"] = "message", ["role"] = "assistant", ["status"] = "completed", + ["content"] = new JArray(new JObject { ["type"] = "output_text", ["text"] = "final answer from wire", ["annotations"] = new JArray() }) + })); + } + + private static FakeWireServer.HttpResponse ResponsesToolCallSse() { + var fcItem = new JObject { + ["id"] = "fc_1", ["type"] = "function_call", ["status"] = "in_progress", + ["call_id"] = "call_1", ["name"] = "fake_echo_tool", ["arguments"] = "" + }; + var fcDone = (JObject)fcItem.DeepClone(); + fcDone["status"] = "completed"; + fcDone["arguments"] = "{\"text\":\"hi\"}"; + return FakeWireServer.HttpResponse.Sse( + ResponsesEvent("response.created", JObject.Parse("{\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":1720000000,\"status\":\"in_progress\",\"model\":\"" + ModelName + "\",\"output\":[],\"usage\":null}}")), + ResponsesEvent("response.output_item.added", new JObject { ["type"] = "response.output_item.added", ["output_index"] = 0, ["item"] = fcItem }), + ResponsesEvent("response.function_call_arguments.delta", new JObject { + ["type"] = "response.function_call_arguments.delta", ["item_id"] = "fc_1", ["output_index"] = 0, ["delta"] = "{\"text\":\"hi\"}" + }), + ResponsesEvent("response.function_call_arguments.done", new JObject { + ["type"] = "response.function_call_arguments.done", ["item_id"] = "fc_1", ["output_index"] = 0, ["arguments"] = "{\"text\":\"hi\"}" + }), + ResponsesEvent("response.output_item.done", new JObject { ["type"] = "response.output_item.done", ["output_index"] = 0, ["item"] = fcDone }), + ResponsesCompleted(fcDone)); + } + + private static string ResponsesEvent(string name, JObject data) => $"event: {name}\ndata: {data.ToString(Newtonsoft.Json.Formatting.None)}"; + + private static string ResponsesCompleted(JObject outputItem) => + ResponsesEvent("response.completed", new JObject { + ["type"] = "response.completed", + ["response"] = new JObject { + ["id"] = "resp_1", ["object"] = "response", ["created_at"] = 1720000000, ["status"] = "completed", + ["model"] = ModelName, + ["output"] = new JArray(outputItem), + ["usage"] = new JObject { ["input_tokens"] = 10, ["output_tokens"] = 5, ["total_tokens"] = 15 } + } + }); + + private static FakeWireServer.HttpResponse MessagesTextSse() => FakeWireServer.HttpResponse.Sse( + MessagesEvent("message_start", "{\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"" + ModelName + "\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}"), + MessagesEvent("content_block_start", "{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}"), + MessagesEvent("content_block_delta", "{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello from the wire\"}}"), + MessagesEvent("content_block_stop", "{\"type\":\"content_block_stop\",\"index\":0}"), + MessagesEvent("message_delta", "{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":5}}"), + MessagesEvent("message_stop", "{\"type\":\"message_stop\"}")); + + private static FakeWireServer.HttpResponse MessagesToolUseSse() => FakeWireServer.HttpResponse.Sse( + MessagesEvent("message_start", "{\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"" + ModelName + "\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}"), + MessagesEvent("content_block_start", "{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"fake_echo_tool\",\"input\":{}}}"), + MessagesEvent("content_block_delta", "{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"text\\\":\\\"hi\\\"}\"}}"), + MessagesEvent("content_block_stop", "{\"type\":\"content_block_stop\",\"index\":0}"), + MessagesEvent("message_delta", "{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":5}}"), + MessagesEvent("message_stop", "{\"type\":\"message_stop\"}")); + + private static FakeWireServer.HttpResponse MessagesFinalTextSse() => FakeWireServer.HttpResponse.Sse( + MessagesEvent("message_start", "{\"type\":\"message_start\",\"message\":{\"id\":\"msg_2\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"" + ModelName + "\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}"), + MessagesEvent("content_block_start", "{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}"), + MessagesEvent("content_block_delta", "{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"final answer from wire\"}}"), + MessagesEvent("content_block_stop", "{\"type\":\"content_block_stop\",\"index\":0}"), + MessagesEvent("message_delta", "{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":5}}"), + MessagesEvent("message_stop", "{\"type\":\"message_stop\"}")); + + private static string MessagesEvent(string name, string data) => $"event: {name}\ndata: {data}"; + + private static FakeWireServer.HttpResponse TextAfterToolSse() => FakeWireServer.HttpResponse.Sse( + ChatChunk("chatcmpl-2", "{\"role\":\"assistant\",\"content\":\"\"}", null), + ChatChunk("chatcmpl-2", "{\"content\":\"final\"}", null), + ChatChunk("chatcmpl-2", "{}", "stop"), + "data: [DONE]"); + + // ==================================================================== + // Chat (OpenAI Chat Completions) + // ==================================================================== + + [Fact] + public async Task Go_Chat_BearerPathBodyToolsAndStream() { + using var server = new FakeWireServer(); + server.Enqueue(ChatTextSse); + var results = await RunChatAsync(server.BaseUrl("/zen/go/v1")); + + // PATH: Go Chat route + var req = server.Requests.Single(); + Assert.Equal("POST", req.Method); + Assert.Equal("/zen/go/v1/chat/completions", req.Path); + + // HEADER: Bearer only, no x-api-key + Assert.Equal($"Bearer {ApiKey}", req.Header("Authorization")); + Assert.False(req.HasHeader("x-api-key")); + + // BODY: OpenAI messages[] with system instruction + user content; tools[] with function type + var body = req.Json; + Assert.True(body.Value("stream")); + Assert.Equal(ModelName, body.Value("model")); + var messages = body["messages"] as JArray; + Assert.NotNull(messages); + Assert.Contains(messages, m => m.Value("role") == "system" && !string.IsNullOrEmpty(m["content"]?.Value())); + Assert.Contains(messages, m => m.Value("role") == "user"); + var tools = body["tools"] as JArray; + Assert.NotNull(tools); + Assert.Contains(tools, t => t.Value("type") == "function" && t["function"]?.Value("name") == "fake_echo_tool"); + + // STREAM: chunks choices[].delta parsed into yielded text + Assert.Contains(results, r => r.Contains("Hello")); + } + + [Fact] + public async Task Go_Chat_ToolRoundTrip_ToolRoleAndCallId() { + using var server = new FakeWireServer(); + server.Enqueue(ChatToolCallSse, TextAfterToolSse()); + var results = await RunChatAsync(server.BaseUrl("/zen/go/v1")); + + Assert.Equal(2, server.Requests.Count); + var req2 = server.Requests[1]; + Assert.Equal("/zen/go/v1/chat/completions", req2.Path); + Assert.Equal($"Bearer {ApiKey}", req2.Header("Authorization")); + Assert.False(req2.HasHeader("x-api-key")); + + // TOOL: 2nd request carries role=tool message with tool_call_id linkage + var messages = req2.Json["messages"] as JArray; + Assert.NotNull(messages); + var toolMsg = messages.FirstOrDefault(m => m.Value("role") == "tool"); + Assert.NotNull(toolMsg); + Assert.Equal("call_1", toolMsg.Value("tool_call_id")); + Assert.Contains("echo:hi", toolMsg["content"]?.Value()); + + Assert.Contains(results, r => r.Contains("final")); + } + + [Fact] + public async Task Zen_Chat_BearerNoXApiKey() { + using var server = new FakeWireServer(); + server.Enqueue(ChatTextSse); + var results = await RunChatAsync(server.BaseUrl("/zen/v1")); + + var req = server.Requests.Single(); + Assert.Equal("/zen/v1/chat/completions", req.Path); + Assert.Equal($"Bearer {ApiKey}", req.Header("Authorization")); + Assert.False(req.HasHeader("x-api-key")); + var messages = req.Json["messages"] as JArray; + Assert.NotNull(messages); + Assert.Contains(messages, m => m.Value("role") == "system"); + Assert.Contains(messages, m => m.Value("role") == "user"); + Assert.Contains(results, r => r.Contains("Hello")); + } + + // ==================================================================== + // Responses (OpenAI Responses API) + // ==================================================================== + + [Fact] + public async Task Go_Responses_BearerInputInstructionsTypedStream() { + using var server = new FakeWireServer(); + server.Enqueue(ResponsesTextSse()); + var results = await RunResponsesAsync(server.BaseUrl("/zen/go/v1")); + + var req = server.Requests.Single(); + Assert.Equal("POST", req.Method); + Assert.Equal("/zen/go/v1/responses", req.Path); + Assert.Equal($"Bearer {ApiKey}", req.Header("Authorization")); + Assert.False(req.HasHeader("x-api-key")); + + // BODY: input items + top-level instructions + var body = req.Json; + Assert.True(body.Value("stream")); + Assert.Equal(ModelName, body.Value("model")); + Assert.False(string.IsNullOrEmpty(body.Value("instructions"))); + var input = body["input"] as JArray; + Assert.NotNull(input); + Assert.True(input.Count > 0); + Assert.Contains(input, i => i.Value("type") == "message" && i["role"]?.Value() == "user"); + + // STREAM: typed SSE (response.output_text.delta) parsed into yielded text + Assert.Contains(results, r => r.Contains("Hello")); + } + + [Fact] + public async Task Go_Responses_ToolRoundTrip_FunctionCallLinkage() { + using var server = new FakeWireServer(); + server.Enqueue(ResponsesToolCallSse(), ResponsesFinalTextSse()); + var results = await RunResponsesAsync(server.BaseUrl("/zen/go/v1")); + + Assert.Equal(2, server.Requests.Count); + var req1 = server.Requests[0]; + var tools = req1.Json["tools"] as JArray; + Assert.NotNull(tools); + Assert.Contains(tools, t => t.Value("type") == "function" && t.Value("name") == "fake_echo_tool"); + + // TOOL: 2nd request carries typed function_call + function_call_output items linked by call_id + var req2 = server.Requests[1]; + Assert.Equal("/zen/go/v1/responses", req2.Path); + Assert.Equal($"Bearer {ApiKey}", req2.Header("Authorization")); + var input = req2.Json["input"] as JArray; + Assert.NotNull(input); + var callItem = input.FirstOrDefault(i => i.Value("type") == "function_call"); + Assert.NotNull(callItem); + Assert.Equal("call_1", callItem.Value("call_id")); + Assert.Equal("fake_echo_tool", callItem.Value("name")); + var outputItem = input.FirstOrDefault(i => i.Value("type") == "function_call_output"); + Assert.NotNull(outputItem); + Assert.Equal("call_1", outputItem.Value("call_id")); + Assert.Contains("echo:hi", outputItem["output"]?.Value()); + + Assert.Contains(results, r => r.Contains("final")); + } + + [Fact] + public async Task Zen_Responses_BearerNoXApiKey() { + using var server = new FakeWireServer(); + server.Enqueue(ResponsesTextSse()); + var results = await RunResponsesAsync(server.BaseUrl("/zen/v1")); + + var req = server.Requests.Single(); + Assert.Equal("/zen/v1/responses", req.Path); + Assert.Equal($"Bearer {ApiKey}", req.Header("Authorization")); + Assert.False(req.HasHeader("x-api-key")); + Assert.False(string.IsNullOrEmpty(req.Json.Value("instructions"))); + var input = req.Json["input"] as JArray; + Assert.NotNull(input); + Assert.True(input.Count > 0); + Assert.Contains(results, r => r.Contains("Hello")); + } + + // ==================================================================== + // Messages (Anthropic Messages) + // ==================================================================== + + [Fact] + public async Task Go_Messages_XApiKeyTopLevelSystemStreamEvents() { + using var server = new FakeWireServer(); + server.Enqueue(MessagesTextSse()); + var results = await RunMessagesAsync(server.BaseUrl("/zen/go/v1")); + + var req = server.Requests.Single(); + Assert.Equal("POST", req.Method); + Assert.Equal("/zen/go/v1/messages", req.Path); + + // HEADER: x-api-key only; no Bearer contamination; anthropic-version (blueprint §三: unknown -> now evidenced) + Assert.Equal(ApiKey, req.Header("x-api-key")); + Assert.False(req.HasHeader("Authorization")); + Assert.Equal("2023-06-01", req.Header("anthropic-version")); + + // BODY: messages[] user/assistant only + top-level system (never a system/developer message role) + var body = req.Json; + Assert.True(body.Value("stream")); + Assert.Equal(ModelName, body.Value("model")); + Assert.NotNull(body["system"]); + Assert.NotEmpty(body["system"].ToString()); + var messages = body["messages"] as JArray; + Assert.NotNull(messages); + Assert.True(messages.Count > 0); + foreach (var m in messages) { + var role = m.Value("role"); + Assert.True(role == "user" || role == "assistant", $"unexpected message role {role}"); + } + + // STREAM: message_start/content_block_*/message_delta/message_stop parsed into yielded text + Assert.Contains(results, r => r.Contains("Hello")); + } + + [Fact] + public async Task Go_Messages_ToolRoundTrip_ToolUseToolResultBlocks() { + using var server = new FakeWireServer(); + server.Enqueue(MessagesToolUseSse(), MessagesFinalTextSse()); + var results = await RunMessagesAsync(server.BaseUrl("/zen/go/v1")); + + Assert.Equal(2, server.Requests.Count); + var req1 = server.Requests[0]; + var tools = req1.Json["tools"] as JArray; + Assert.NotNull(tools); + Assert.Contains(tools, t => t["name"]?.Value() == "fake_echo_tool"); + + // TOOL: 2nd request carries assistant tool_use block + user tool_result block (tool_result inside a user message) + var req2 = server.Requests[1]; + Assert.Equal("/zen/go/v1/messages", req2.Path); + Assert.Equal(ApiKey, req2.Header("x-api-key")); + Assert.False(req2.HasHeader("Authorization")); + var messages = req2.Json["messages"] as JArray; + Assert.NotNull(messages); + var assistantMsg = messages.FirstOrDefault(m => m.Value("role") == "assistant"); + Assert.NotNull(assistantMsg); + var toolUseBlock = assistantMsg["content"]?.FirstOrDefault(c => c.Value("type") == "tool_use"); + Assert.NotNull(toolUseBlock); + Assert.Equal("toolu_1", toolUseBlock.Value("id")); + Assert.Equal("fake_echo_tool", toolUseBlock.Value("name")); + var userMsg = messages.FirstOrDefault(m => m.Value("role") == "user" && m["content"] is JArray); + Assert.NotNull(userMsg); + var toolResultBlock = userMsg["content"]?.FirstOrDefault(c => c.Value("type") == "tool_result"); + Assert.NotNull(toolResultBlock); + Assert.Equal("toolu_1", toolResultBlock.Value("tool_use_id")); + Assert.Contains("echo:hi", toolResultBlock["content"]?.ToString()); + + Assert.Contains(results, r => r.Contains("final")); + } + + [Fact] + public async Task Zen_Messages_XApiKeyNoBearer() { + using var server = new FakeWireServer(); + server.Enqueue(MessagesTextSse()); + var results = await RunMessagesAsync(server.BaseUrl("/zen/v1")); + + var req = server.Requests.Single(); + Assert.Equal("/zen/v1/messages", req.Path); + Assert.Equal(ApiKey, req.Header("x-api-key")); + Assert.False(req.HasHeader("Authorization")); + Assert.NotNull(req.Json["system"]); + var messages = req.Json["messages"] as JArray; + Assert.NotNull(messages); + foreach (var m in messages) { + var role = m.Value("role"); + Assert.True(role == "user" || role == "assistant"); + } + Assert.Contains(results, r => r.Contains("Hello")); + } + } +} diff --git a/TelegramSearchBot.LLM/Interface/AI/LLM/IGeneralLLMService.cs b/TelegramSearchBot.LLM/Interface/AI/LLM/IGeneralLLMService.cs index 80c41914..e26cf990 100644 --- a/TelegramSearchBot.LLM/Interface/AI/LLM/IGeneralLLMService.cs +++ b/TelegramSearchBot.LLM/Interface/AI/LLM/IGeneralLLMService.cs @@ -13,7 +13,7 @@ public interface IGeneralLLMService { IAsyncEnumerable ExecAsync(Message message, long ChatId, CancellationToken cancellationToken = default); IAsyncEnumerable ExecAsync(Message message, long ChatId, LlmExecutionContext executionContext, CancellationToken cancellationToken = default); IAsyncEnumerable ExecAsync(Message message, long ChatId, string modelName, ILLMService service, LLMChannel channel, CancellationToken cancellation); - IAsyncEnumerable ExecOperationAsync(Func> operation, string modelName, CancellationToken cancellationToken = default); + IAsyncEnumerable ExecOperationAsync(Func> operation, string modelName, CancellationToken cancellationToken = default); /// /// Resume LLM execution from a previously saved continuation snapshot. diff --git a/TelegramSearchBot.LLM/Interface/AI/LLM/ILLMFactory.cs b/TelegramSearchBot.LLM/Interface/AI/LLM/ILLMFactory.cs index 9e080b74..7336ca70 100644 --- a/TelegramSearchBot.LLM/Interface/AI/LLM/ILLMFactory.cs +++ b/TelegramSearchBot.LLM/Interface/AI/LLM/ILLMFactory.cs @@ -1,7 +1,16 @@ using TelegramSearchBot.Model.AI; +using TelegramSearchBot.Service.AI.LLM; namespace TelegramSearchBot.Interface.AI.LLM { public interface ILLMFactory : IService { + /// 按 legacy 品牌/订阅枚举选择服务(Phase-3 调用方仍使用,必须保持可用)。 ILLMService GetLLMService(LLMProvider provider); + + /// 按 binding 线协议选择服务:OpenAIChat→OpenAIService,OpenAIResponses→OpenAIResponsesService, + /// AnthropicMessages→AnthropicService,Ollama→OllamaService,Gemini→GeminiService。 + ILLMService GetLLMService(LlmProtocol protocol); + + /// 按解析路由选择服务:有 binding 时按 binding.Protocol,否则(legacy 临时路由)按 channel.Provider。 + ILLMService GetLLMService(ResolvedLlmRoute route); } } diff --git a/TelegramSearchBot.LLM/Interface/AI/LLM/ILLMService.cs b/TelegramSearchBot.LLM/Interface/AI/LLM/ILLMService.cs index c812158d..55732c73 100644 --- a/TelegramSearchBot.LLM/Interface/AI/LLM/ILLMService.cs +++ b/TelegramSearchBot.LLM/Interface/AI/LLM/ILLMService.cs @@ -44,5 +44,34 @@ public IAsyncEnumerable ResumeFromSnapshotAsync(LlmContinuationSnapshot public Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, string prompt = null); public virtual async Task IsHealthyAsync(LLMChannel channel) => ( await GetAllModels(channel) ).Any(); + + // ==================================================================== + // Binding-aware overloads (Phase 2): carry the resolved LLMApiBinding so + // endpoint/auth come from the binding. Default implementations delegate to + // the existing channel-only methods, preserving legacy behavior when the + // binding is null (or when a service does not override them). + // ==================================================================== + + public IAsyncEnumerable ExecAsync(Message message, long ChatId, string modelName, LLMChannel channel, + LLMApiBinding binding, + LlmExecutionContext executionContext, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + return ExecAsync(message, ChatId, modelName, channel, executionContext, cancellationToken); + } + + public IAsyncEnumerable ResumeFromSnapshotAsync(LlmContinuationSnapshot snapshot, LLMChannel channel, + LLMApiBinding binding, + LlmExecutionContext executionContext, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + return ResumeFromSnapshotAsync(snapshot, channel, executionContext, cancellationToken); + } + + public Task IsHealthyAsync(LLMChannel channel, LLMApiBinding binding) => IsHealthyAsync(channel); + + public Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, LLMApiBinding binding, string prompt = null) + => AnalyzeImageAsync(photoPath, modelName, channel, prompt); + + public Task GenerateEmbeddingsAsync(string text, string modelName, LLMChannel channel, LLMApiBinding binding) + => GenerateEmbeddingsAsync(text, modelName, channel); } } diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs index 9b9b4fe1..e7964d6c 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs @@ -93,12 +93,18 @@ private async Task GetBotNameAsync() { return identity.UserName ?? string.Empty; } - private AnthropicClient CreateClient(LLMChannel channel) { + private AnthropicClient CreateClient(LLMChannel channel, LLMApiBinding? binding = null) { var options = new Anthropic.Core.ClientOptions { - ApiKey = channel.ApiKey, + ApiKey = LlmBindingSupport.ResolveApiKey(channel, binding), }; - if (!string.IsNullOrWhiteSpace(channel.Gateway)) { - options.BaseUrl = channel.Gateway.TrimEnd('/'); + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + if (!string.IsNullOrWhiteSpace(endpoint)) { + // Binding URL 已含 /v1(如 https://opencode.ai/zen/v1),SDK 会再追加 /v1/messages; + // 剥离尾部 /v1 使 SDK 追加后命中精确 binding 路径。legacy channel.Gateway 保持字节一致。 + if (binding != null && endpoint.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) { + endpoint = endpoint.Substring(0, endpoint.Length - 3); + } + options.BaseUrl = endpoint.TrimEnd('/'); } return new AnthropicClient(options); } @@ -607,9 +613,21 @@ public async IAsyncEnumerable ExecAsync( DataMessage message, long ChatId, string modelName, LLMChannel channel, LlmExecutionContext executionContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + await foreach (var item in ExecAsync(message, ChatId, modelName, channel, null, executionContext, cancellationToken)) { + yield return item; + } + } + + public async IAsyncEnumerable ExecAsync( + DataMessage message, long ChatId, string modelName, LLMChannel channel, + LLMApiBinding binding, + LlmExecutionContext executionContext, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(modelName)) modelName = "claude-sonnet-4-20250514"; - if (channel == null || string.IsNullOrWhiteSpace(channel.ApiKey)) { + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + var apiKey = LlmBindingSupport.ResolveApiKey(channel, binding); + if (channel == null || string.IsNullOrWhiteSpace(endpoint) || (binding?.AuthProfile != LlmAuthProfile.None && string.IsNullOrWhiteSpace(apiKey))) { _logger.LogError("{ServiceName}: Channel or ApiKey is not configured.", ServiceName); yield return $"Error: {ServiceName} channel/apikey is not configured."; yield break; @@ -625,7 +643,7 @@ public async IAsyncEnumerable ExecAsync( if (useNativeToolCalling) { bool nativeFailed = false; - var nativeEnumerator = ExecWithNativeToolCallingAsync(message, ChatId, modelName, channel, executionContext, nativeTools, cancellationToken); + var nativeEnumerator = ExecWithNativeToolCallingAsync(message, ChatId, modelName, channel, binding, executionContext, nativeTools, cancellationToken); await using var enumerator = nativeEnumerator.GetAsyncEnumerator(cancellationToken); bool hasFirst = false; try { @@ -647,7 +665,7 @@ public async IAsyncEnumerable ExecAsync( } // Fallback: XML prompt-based tool calling - await foreach (var item in ExecWithXmlToolCallingAsync(message, ChatId, modelName, channel, executionContext, cancellationToken)) { + await foreach (var item in ExecWithXmlToolCallingAsync(message, ChatId, modelName, channel, binding, executionContext, cancellationToken)) { yield return item; } } @@ -665,6 +683,7 @@ private static bool IsToolCallingNotSupportedError(Exception ex) { /// private async IAsyncEnumerable ExecWithNativeToolCallingAsync( DataMessage message, long ChatId, string modelName, LLMChannel channel, + LLMApiBinding binding, LlmExecutionContext executionContext, List nativeTools, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { @@ -684,7 +703,7 @@ private async IAsyncEnumerable ExecWithNativeToolCallingAsync( SerializeProviderHistory(systemPrompt, stableHistory)); providerHistory = PrepareMessagesForPromptCaching(providerHistory, promptCachingEnabled, excludeDynamicTail: true, out var cacheBreakpointInserted); - using var client = CreateClient(channel); + using var client = CreateClient(channel, binding); var anthropicTools = ConvertToAnthropicTools(nativeTools, promptCachingEnabled); int maxToolCycles = Env.MaxToolCycles; @@ -911,6 +930,7 @@ private async IAsyncEnumerable ExecWithNativeToolCallingAsync( /// private async IAsyncEnumerable ExecWithXmlToolCallingAsync( DataMessage message, long ChatId, string modelName, LLMChannel channel, + LLMApiBinding binding, LlmExecutionContext executionContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { using var chatContentLogScope = LoggerHolders.PushChatContentLogScope(); @@ -929,7 +949,7 @@ private async IAsyncEnumerable ExecWithXmlToolCallingAsync( SerializeProviderHistory(systemPrompt, stableHistory)); providerHistory = PrepareMessagesForPromptCaching(providerHistory, promptCachingEnabled, excludeDynamicTail: true, out var cacheBreakpointInserted); - using var client = CreateClient(channel); + using var client = CreateClient(channel, binding); int maxToolCycles = Env.MaxToolCycles; var currentMessageContentBuilder = new StringBuilder(); @@ -1090,12 +1110,24 @@ public async IAsyncEnumerable ResumeFromSnapshotAsync( LlmContinuationSnapshot snapshot, LLMChannel channel, LlmExecutionContext executionContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + await foreach (var item in ResumeFromSnapshotAsync(snapshot, channel, null, executionContext, cancellationToken)) { + yield return item; + } + } + + public async IAsyncEnumerable ResumeFromSnapshotAsync( + LlmContinuationSnapshot snapshot, LLMChannel channel, + LLMApiBinding binding, + LlmExecutionContext executionContext, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { using var chatContentLogScope = LoggerHolders.PushChatContentLogScope(); if (snapshot == null) { _logger.LogError("{ServiceName}: Cannot resume from null snapshot.", ServiceName); yield break; } - if (channel == null || string.IsNullOrWhiteSpace(channel.ApiKey)) { + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + var apiKey = LlmBindingSupport.ResolveApiKey(channel, binding); + if (channel == null || string.IsNullOrWhiteSpace(endpoint) || (binding?.AuthProfile != LlmAuthProfile.None && string.IsNullOrWhiteSpace(apiKey))) { _logger.LogError("{ServiceName}: Channel or ApiKey is not configured for resume.", ServiceName); yield break; } @@ -1114,7 +1146,7 @@ public async IAsyncEnumerable ResumeFromSnapshotAsync( snapshot.ProviderHistory ?? []); providerHistory = PrepareMessagesForPromptCaching(providerHistory, promptCachingEnabled, excludeDynamicTail: false, out var cacheBreakpointInserted); - using var client = CreateClient(channel); + using var client = CreateClient(channel, binding); var fullContentBuilder = new StringBuilder(snapshot.LastAccumulatedContent ?? ""); var newContentBuilder = new StringBuilder(); @@ -1329,18 +1361,24 @@ public Task GenerateEmbeddingsAsync(string text, string modelName, LLMC #region Image Analysis public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, string prompt = null) { + return await AnalyzeImageAsync(photoPath, modelName, channel, null, prompt); + } + + public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, LLMApiBinding binding, string prompt = null) { if (string.IsNullOrWhiteSpace(modelName)) { modelName = "claude-sonnet-4-20250514"; } prompt = string.IsNullOrWhiteSpace(prompt) ? GeneralLLMService.DefaultAltPhotoPrompt : prompt; - if (channel == null || string.IsNullOrWhiteSpace(channel.ApiKey)) { + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + var apiKey = LlmBindingSupport.ResolveApiKey(channel, binding); + if (channel == null || string.IsNullOrWhiteSpace(endpoint) || (binding?.AuthProfile != LlmAuthProfile.None && string.IsNullOrWhiteSpace(apiKey))) { _logger.LogError("{ServiceName}: Channel or ApiKey is not configured.", ServiceName); return $"Error: {ServiceName} channel/apikey is not configured."; } - using var client = CreateClient(channel); + using var client = CreateClient(channel, binding); try { using var fileStream = File.OpenRead(photoPath); diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs index d380f7d0..4d3e14d2 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs @@ -86,8 +86,8 @@ public async IAsyncEnumerable ExecAsync(Model.Data.Message message, long yield break; } - await foreach (var e in ExecOperationAsync((service, channel, cancel) => { - return service.ExecAsync(message, ChatId, modelName, channel, executionContext, cancellationToken); + await foreach (var e in ExecOperationAsync((service, channel, binding, cancel) => { + return service.ExecAsync(message, ChatId, modelName, channel, binding, executionContext, cancellationToken); }, modelName, cancellationToken)) { yield return e; } @@ -119,6 +119,7 @@ public async IAsyncEnumerable ResumeFromSnapshotAsync( // Find the channel by ID var channel = await _dbContext.LLMChannels + .Include(c => c.Bindings) .FirstOrDefaultAsync(c => c.Id == snapshot.ChannelId); if (channel == null) { _logger.LogError("Cannot resume: channel {ChannelId} not found", snapshot.ChannelId); @@ -136,36 +137,49 @@ public async IAsyncEnumerable ResumeFromSnapshotAsync( yield break; } - var service = _LLMFactory.GetLLMService(channel.Provider); - _logger.LogInformation("Resuming from snapshot {SnapshotId} using provider {Provider}, channel {ChannelId}", snapshot.SnapshotId, channel.Provider, channel.Id); - await foreach (var item in service.ResumeFromSnapshotAsync(snapshot, channel, executionContext, cancellationToken) + // 确定性路由:按 snapshot 的模型 + 渠道解析 binding(legacy 回退 provider/gateway) + var modelRows = await _dbContext.ChannelsWithModel + .Include(s => s.ApiBinding) + .Where(s => s.LLMChannelId == channel.Id && s.ModelName == snapshot.ModelName && !s.IsDeleted) + .ToListAsync(); + var route = LlmRouteResolver.Resolve(channel, snapshot.ModelName, modelRows, _logger); + if (route == null) { + _logger.LogError("Cannot resume: model {Model} has no route on channel {ChannelId}", snapshot.ModelName, channel.Id); + yield break; + } + + var service = _LLMFactory.GetLLMService(route); + + await foreach (var item in service.ResumeFromSnapshotAsync(snapshot, channel, route.Binding, executionContext, cancellationToken) .WithCancellation(cancellationToken)) { yield return item; } } public async IAsyncEnumerable ExecOperationAsync( - Func> operation, + Func> operation, string modelName, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default ) { - // 2. 查询ChannelWithModel获取关联的LLMChannel(排除已软删除的模型) - var channelsWithModel = await ( from s in _dbContext.ChannelsWithModel - where s.ModelName == modelName && !s.IsDeleted - select s.LLMChannelId ).ToListAsync(); - + // 2. 查询ChannelWithModel获取关联的模型行(含 ApiBinding 导航,排除已软删除的模型) + var modelRows = await _dbContext.ChannelsWithModel + .Include(s => s.ApiBinding) + .Where(s => s.ModelName == modelName && !s.IsDeleted) + .ToListAsync(); - if (!channelsWithModel.Any()) { + if (!modelRows.Any()) { _logger.LogWarning($"找不到模型 {modelName} 的配置"); yield break; } - // 3. 获取关联的LLMChannel并按优先级排序 + // 3. 获取关联的LLMChannel(含 Bindings)并按优先级排序 + var channelIds = modelRows.Select(r => r.LLMChannelId).Distinct().ToList(); var llmChannels = await ( from s in _dbContext.LLMChannels - where channelsWithModel.Contains(s.Id) + .Include(c => c.Bindings) + where channelIds.Contains(s.Id) orderby s.Priority descending select s ).ToListAsync(); if (!llmChannels.Any()) { @@ -183,7 +197,12 @@ orderby s.Priority descending var redisKey = $"llm:channel:{channel.Id}:semaphore"; var currentCount = await redisDb.StringGetAsync(redisKey); int count = currentCount.HasValue ? ( int ) currentCount : 0; - var service = _LLMFactory.GetLLMService(channel.Provider); + + // 确定性路由:渠道内按 IsPreferred/IsDefault/binding.Id 解析(legacy 回退 provider/gateway) + var route = LlmRouteResolver.Resolve(channel, modelName, + modelRows.Where(r => r.LLMChannelId == channel.Id).ToList(), _logger); + if (route == null) continue; + var service = _LLMFactory.GetLLMService(route); if (count < channel.Parallel) { // 获取锁并增加计数 @@ -192,7 +211,7 @@ orderby s.Priority descending // 5. 检查服务是否可用 bool isHealthy = false; try { - isHealthy = await service.IsHealthyAsync(channel); + isHealthy = await service.IsHealthyAsync(channel, route.Binding); } catch (Exception ex) { _logger.LogWarning(ex, $"LLM渠道 {channel.Id} ({channel.Provider}) 健康检查失败"); continue; @@ -203,8 +222,8 @@ orderby s.Priority descending continue; } - // 6. 根据Provider选择服务 - await foreach (var e in operation(service, channel, new CancellationToken())) { + // 6. 按解析路由执行 + await foreach (var e in operation(service, channel, route.Binding, new CancellationToken())) { yield return e; } yield break; @@ -239,8 +258,8 @@ public async Task AnalyzeImageAsync(string PhotoPath, long ChatId, strin modelName = config.Value; } - await using var enumerator = ExecOperationAsync((service, channel, cancel) => { - return AnalyzeImageAsync(PhotoPath, ChatId, modelName, service, channel, prompt, cancel); + await using var enumerator = ExecOperationAsync((service, channel, binding, cancel) => { + return AnalyzeImageAsync(PhotoPath, ChatId, modelName, service, channel, binding, prompt, cancel); }, modelName, cancellationToken).GetAsyncEnumerator(); if (await enumerator.MoveNextAsync()) { @@ -262,6 +281,12 @@ public async IAsyncEnumerable AnalyzeImageAsync(string PhotoPath, long C yield break; } + public async IAsyncEnumerable AnalyzeImageAsync(string PhotoPath, long ChatId, string modelName, ILLMService service, LLMChannel channel, LLMApiBinding binding, string prompt, CancellationToken cancellationToken = default) { + prompt = string.IsNullOrWhiteSpace(prompt) ? DefaultAltPhotoPrompt : prompt; + yield return await service.AnalyzeImageAsync(PhotoPath, modelName, channel, binding, prompt); + yield break; + } + public async Task GenerateEmbeddingsAsync(Model.Data.Message message, long ChatId) { var modelName = "bge-m3:latest"; var config = await _dbContext.AppConfigurationItems @@ -281,8 +306,8 @@ public async Task GenerateEmbeddingsAsync(string message, CancellationT modelName = config.Value; } - await using var enumerator = ExecOperationAsync((service, channel, cancel) => { - return GenerateEmbeddingsAsync(message, modelName, service, channel, cancel); + await using var enumerator = ExecOperationAsync((service, channel, binding, cancel) => { + return GenerateEmbeddingsAsync(message, modelName, service, channel, binding, cancel); }, modelName, cancellationToken).GetAsyncEnumerator(); if (await enumerator.MoveNextAsync()) { @@ -296,6 +321,10 @@ public async IAsyncEnumerable GenerateEmbeddingsAsync(string message, s yield return await service.GenerateEmbeddingsAsync(message, modelName, channel); yield break; } + public async IAsyncEnumerable GenerateEmbeddingsAsync(string message, string modelName, ILLMService service, LLMChannel channel, LLMApiBinding binding, CancellationToken cancellationToken = default) { + yield return await service.GenerateEmbeddingsAsync(message, modelName, channel, binding); + yield break; + } private async Task GetMaxRetryCountAsync() { var config = await _dbContext.AppConfigurationItems diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/LLMFactory.cs b/TelegramSearchBot.LLM/Service/AI/LLM/LLMFactory.cs index bebfdcf0..397155d4 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/LLMFactory.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/LLMFactory.cs @@ -36,5 +36,22 @@ public ILLMService GetLLMService(LLMProvider provider) { }; } + public ILLMService GetLLMService(LlmProtocol protocol) { + return protocol switch { + LlmProtocol.OpenAIChat => _serviceProvider.GetRequiredService(), + LlmProtocol.OpenAIResponses => _serviceProvider.GetRequiredService(), + LlmProtocol.AnthropicMessages => _serviceProvider.GetRequiredService(), + LlmProtocol.Ollama => _serviceProvider.GetRequiredService(), + LlmProtocol.Gemini => _serviceProvider.GetRequiredService(), + _ => throw new KeyNotFoundException($"No LLM service registered for protocol {protocol}.") + }; + } + + public ILLMService GetLLMService(ResolvedLlmRoute route) { + return route.Binding != null + ? GetLLMService(route.Binding.Protocol) + : GetLLMService(route.Channel.Provider); + } + } } diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/LlmRouteResolver.cs b/TelegramSearchBot.LLM/Service/AI/LLM/LlmRouteResolver.cs new file mode 100644 index 00000000..95646b44 --- /dev/null +++ b/TelegramSearchBot.LLM/Service/AI/LLM/LlmRouteResolver.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using TelegramSearchBot.Model.AI; +using TelegramSearchBot.Model.Data; + +namespace TelegramSearchBot.Service.AI.LLM { + /// + /// 运行时解析出的确定路由:channel(品牌/共享 secret)+ binding(endpoint/协议/认证)+ 模型行。 + /// Binding 为 null 表示 legacy 临时路由(channel.Provider/Gateway 回退)。 + /// General 路径与 Agent 路径共用。 + /// + public sealed record ResolvedLlmRoute(LLMChannel Channel, LLMApiBinding? Binding, ChannelWithModel Model) { + /// 是否为 legacy 临时路由(无 binding,走 channel.Provider/Gateway)。 + public bool IsLegacyFallback => Binding == null; + } + + /// + /// 确定性路由解析:General 路径与 Agent 路径共用的唯一选择逻辑,避免协议分叉。 + /// 规则:渠道 Priority DESC 由调用方保证;渠道内先取唯一 IsPreferred=true 行, + /// 否则取有效默认 binding 行(ApiBindingId==null 解释为渠道默认 binding), + /// 最终以 (binding.Id, 行 Id) 稳定排序;数据异常仅告警,绝不 throw。 + /// 协议永远不按模型名/厂商/Gateway 字符串猜测(见 blueprint §六.7)。 + /// + public static class LlmRouteResolver { + /// + /// 在单个渠道内为 modelName 解析路由。rows 必须是该渠道 + 该模型 + !IsDeleted 的已加载行, + /// 且 ApiBindingId!=null 的行需已 Include(ApiBinding),channel.Bindings 需已加载。 + /// + public static ResolvedLlmRoute? Resolve(LLMChannel channel, string modelName, IReadOnlyList rows, ILogger logger) { + if (channel == null) return null; + if (rows == null || rows.Count == 0) { + Log(logger, () => $"模型 {modelName} 在渠道 {channel.Id} 无可用配置行"); + return null; + } + + // 渠道级默认 binding(异常:多个 IsDefault → 稳定排序 + 告警,不 throw) + var defaults = (channel.Bindings ?? Enumerable.Empty()) + .Where(b => b.IsDefault) + .OrderBy(b => b.Id) + .ToList(); + if (defaults.Count > 1) { + Log(logger, () => $"渠道 {channel.Id} 存在多个 IsDefault binding({defaults.Count} 个),按 binding.Id 稳定排序,请管理员修复"); + } + var defaultBinding = defaults.FirstOrDefault(); + + List selected; + var preferred = rows.Where(r => r.IsPreferred).ToList(); + if (preferred.Count > 0) { + if (preferred.Count > 1) { + Log(logger, () => $"模型 {modelName} 渠道 {channel.Id} 存在多个 IsPreferred 行({preferred.Count} 个),按 binding.Id 稳定排序,请管理员修复"); + } + selected = preferred; + } else { + // ApiBindingId==null 的 legacy 行解释为渠道默认 binding;无默认 binding 时走 legacy 回退 + var effectiveDefaults = rows + .Where(r => r.ApiBindingId == null ? defaultBinding != null : (r.ApiBinding?.IsDefault ?? false)) + .ToList(); + if (effectiveDefaults.Count > 0) { + var distinctBindingIds = effectiveDefaults.Select(r => r.ApiBindingId ?? defaultBinding!.Id).Distinct().ToList(); + if (distinctBindingIds.Count > 1) { + Log(logger, () => $"模型 {modelName} 渠道 {channel.Id} 存在多个默认 binding 候选({distinctBindingIds.Count} 个),按 binding.Id 稳定排序,请管理员修复"); + } + selected = effectiveDefaults; + } else { + Log(logger, () => $"模型 {modelName} 渠道 {channel.Id} 无默认 binding(legacy 行),临时回退 channel.Provider/Gateway,请管理员补建默认 binding"); + selected = rows.ToList(); + } + } + + var pick = selected + .OrderBy(r => r.ApiBindingId ?? defaultBinding?.Id ?? int.MaxValue) + .ThenBy(r => r.Id) + .First(); + + var binding = pick.ApiBindingId != null ? pick.ApiBinding : defaultBinding; + if (pick.ApiBindingId != null && pick.ApiBinding == null) { + // 调用方未加载 ApiBinding 导航:防御性回退渠道默认,不 throw + Log(logger, () => $"模型 {modelName} 渠道 {channel.Id} 行 {pick.Id} 的 ApiBinding 未加载,回退渠道默认 binding"); + binding = defaultBinding; + } + return new ResolvedLlmRoute(channel, binding, pick); + } + + /// + /// 按渠道 Priority DESC 顺序传入候选(每渠道的模型行),返回第一个可解析路由。 + /// 供 Agent 路径使用;选择核心仍为 。 + /// + public static ResolvedLlmRoute? ResolveFirst(IEnumerable<(LLMChannel Channel, List Rows)> candidates, string modelName, ILogger logger) { + foreach (var (channel, rows) in candidates) { + var route = Resolve(channel, modelName, rows, logger); + if (route != null) return route; + } + return null; + } + + private static void Log(ILogger logger, Func message) { + if (logger != null) logger.LogWarning(message()); + } + } + + /// + /// binding → 客户端构造参数的最小共享映射:endpoint 取 binding(缺省回退 channel.Gateway); + /// ApiKey 始终共享自 channel(blueprint §六.1);AuthProfile=None 走无 key 路径(空 key,本地/无鉴权端点)。 + /// 认证传输由各 SDK 原生实现:OpenAI SDK 发 Authorization: Bearer,Anthropic SDK 发 x-api-key。 + /// + public static class LlmBindingSupport { + public static string ResolveEndpoint(LLMChannel channel, LLMApiBinding? binding) + => !string.IsNullOrWhiteSpace(binding?.Endpoint) ? binding!.Endpoint : channel?.Gateway ?? string.Empty; + + public static string ResolveApiKey(LLMChannel channel, LLMApiBinding? binding) + => binding?.AuthProfile == LlmAuthProfile.None ? string.Empty : channel?.ApiKey ?? string.Empty; + } +} diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs index 2e629f5b..42d08acf 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/ModelCapabilityService.cs @@ -57,7 +57,7 @@ public async Task UpdateChannelModelCapabilities(int channelId) { var modelsWithCapabilities = await service.GetAllModelsWithCapabilities(channel); foreach (var modelWithCaps in modelsWithCapabilities) { - await UpdateOrCreateModelWithCapabilities(channel, modelWithCaps); + await UpdateExistingModelWithCapabilities(channel, modelWithCaps); } await _dbContext.SaveChangesAsync(); @@ -214,42 +214,42 @@ public async Task CleanupOldCapabilities(int daysOld = 30) { } /// - /// 更新或创建模型及其能力信息 + /// 将 metadata 合并到已存在的非删除授权行上(blueprint §四.6): + /// 绝不创建新行、绝不复活(IsDeleted=false)。无匹配行时跳过并记录 debug。 + /// 模型名按 OrdinalIgnoreCase 匹配(blueprint §七.6)。 /// - private async Task UpdateOrCreateModelWithCapabilities(LLMChannel channel, ModelWithCapabilities modelWithCaps) { - var existingModel = await _dbContext.ChannelsWithModel + private async Task UpdateExistingModelWithCapabilities(LLMChannel channel, ModelWithCapabilities modelWithCaps) { + var existingModels = await _dbContext.ChannelsWithModel .Include(c => c.Capabilities) - .FirstOrDefaultAsync(c => c.ModelName == modelWithCaps.ModelName && c.LLMChannelId == channel.Id); - - if (existingModel == null) { - // 创建新模型记录 - existingModel = new ChannelWithModel { - ModelName = modelWithCaps.ModelName, - LLMChannelId = channel.Id, - IsDeleted = false, - Capabilities = new List() - }; - _dbContext.ChannelsWithModel.Add(existingModel); - await _dbContext.SaveChangesAsync(); // 保存以获取ID - } else { - // 确保软删除标记被清除(能力更新说明模型依然存在) - existingModel.IsDeleted = false; + .Where(c => !c.IsDeleted && c.LLMChannelId == channel.Id) + .ToListAsync(); + + var targets = existingModels + .Where(c => c.ModelName.Equals(modelWithCaps.ModelName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (targets.Count == 0) { + // Catalog/metadata 不得扩大授权集合:无已授权行时不创建、不复活 + _logger.LogDebug("模型 {ModelName} 渠道 {ChannelId} 无已存在的非删除授权行,跳过 metadata 合并", modelWithCaps.ModelName, channel.Id); + return; } - // 删除现有能力信息 - var existingCapabilities = existingModel.Capabilities.ToList(); - _dbContext.ModelCapabilities.RemoveRange(existingCapabilities); - - // 添加新的能力信息 - foreach (var capability in modelWithCaps.Capabilities) { - var modelCapability = new ModelCapability { - ChannelWithModelId = existingModel.Id, - CapabilityName = capability.Key, - CapabilityValue = capability.Value, - Description = GetCapabilityDescription(capability.Key), - LastUpdated = DateTime.UtcNow - }; - _dbContext.ModelCapabilities.Add(modelCapability); + foreach (var existingModel in targets) { + // 删除现有能力信息 + var existingCapabilities = existingModel.Capabilities.ToList(); + _dbContext.ModelCapabilities.RemoveRange(existingCapabilities); + + // 添加新的能力信息 + foreach (var capability in modelWithCaps.Capabilities) { + var modelCapability = new ModelCapability { + ChannelWithModelId = existingModel.Id, + CapabilityName = capability.Key, + CapabilityValue = capability.Value, + Description = GetCapabilityDescription(capability.Key), + LastUpdated = DateTime.UtcNow + }; + _dbContext.ModelCapabilities.Add(modelCapability); + } } } diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs index a4b4b7bb..12c70d52 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs @@ -127,6 +127,15 @@ public async IAsyncEnumerable ExecAsync(Model.Data.Message message, long public async IAsyncEnumerable ExecAsync(Model.Data.Message message, long ChatId, string modelName, LLMChannel channel, LlmExecutionContext executionContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + await foreach (var item in ExecAsync(message, ChatId, modelName, channel, null, executionContext, cancellationToken)) { + yield return item; + } + } + + public async IAsyncEnumerable ExecAsync(Model.Data.Message message, long ChatId, string modelName, LLMChannel channel, + LLMApiBinding binding, + LlmExecutionContext executionContext, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { using var chatContentLogScope = LoggerHolders.PushChatContentLogScope(); modelName = modelName ?? Env.OllamaModelName; if (string.IsNullOrWhiteSpace(modelName)) { @@ -134,7 +143,8 @@ public async IAsyncEnumerable ExecAsync(Model.Data.Message message, long yield return $"Error: {ServiceName} model name is not configured."; yield break; } - if (channel == null || string.IsNullOrWhiteSpace(channel.Gateway)) { + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + if (channel == null || string.IsNullOrWhiteSpace(endpoint)) { _logger.LogError("{ServiceName}: Channel or Gateway is not configured.", ServiceName); yield return $"Error: {ServiceName} channel/gateway is not configured."; yield break; @@ -142,7 +152,7 @@ public async IAsyncEnumerable ExecAsync(Model.Data.Message message, long // --- Client and Model Setup --- HttpClient httpClient = _httpClientFactory?.CreateClient("OllamaClient") ?? new HttpClient(); - httpClient.BaseAddress = new Uri(channel.Gateway); + httpClient.BaseAddress = new Uri(endpoint); var ollama = new OllamaApiClient(httpClient, modelName); if (!await CheckAndPullModelAsync(ollama, modelName)) { @@ -279,6 +289,32 @@ public virtual async Task> GetAllModels(LLMChannel channel) } } + public virtual async Task> GetAllModels(LLMChannel channel, LLMApiBinding binding) { + if (channel == null) return Enumerable.Empty(); + if (binding == null) return await GetAllModels(channel); + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + if (string.IsNullOrWhiteSpace(endpoint)) { + return Enumerable.Empty(); + } + + try { + var httpClient = _httpClientFactory?.CreateClient() ?? new HttpClient(); + httpClient.BaseAddress = new Uri(endpoint); + var ollama = new OllamaApiClient(httpClient); + + var models = await ollama.ListLocalModelsAsync(); + return models.Select(m => m.Name); + } catch (Exception ex) { + _logger.LogError(ex, "Error getting Ollama models"); + return Enumerable.Empty(); + } + } + + public async Task IsHealthyAsync(LLMChannel channel, LLMApiBinding binding) { + var models = await GetAllModels(channel, binding); + return models.Any(); + } + /// /// 获取Ollama模型及其能力信息 /// @@ -402,12 +438,16 @@ private string ExtractModelFamily(string modelName) { } public async Task GenerateEmbeddingsAsync(string text, string modelName, LLMChannel channel) { + return await GenerateEmbeddingsAsync(text, modelName, channel, null); + } + + public async Task GenerateEmbeddingsAsync(string text, string modelName, LLMChannel channel, LLMApiBinding binding) { if (string.IsNullOrWhiteSpace(modelName)) { modelName = "bge-m3"; } var httpClient = _httpClientFactory?.CreateClient() ?? new HttpClient(); - httpClient.BaseAddress = new Uri(channel.Gateway); + httpClient.BaseAddress = new Uri(LlmBindingSupport.ResolveEndpoint(channel, binding)); var ollama = new OllamaApiClient(httpClient, modelName); if (!await CheckAndPullModelAsync(ollama, modelName)) { @@ -429,6 +469,10 @@ public async Task GenerateEmbeddingsAsync(string text, string modelName } public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, string prompt = null) { + return await AnalyzeImageAsync(photoPath, modelName, channel, null, prompt); + } + + public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, LLMApiBinding binding, string prompt = null) { if (string.IsNullOrWhiteSpace(modelName)) { modelName = "gemma3:27b"; } @@ -436,7 +480,7 @@ public async Task AnalyzeImageAsync(string photoPath, string modelName, prompt = string.IsNullOrWhiteSpace(prompt) ? GeneralLLMService.DefaultAltPhotoPrompt : prompt; var httpClient = _httpClientFactory?.CreateClient() ?? new HttpClient(); - httpClient.BaseAddress = new Uri(channel.Gateway); + httpClient.BaseAddress = new Uri(LlmBindingSupport.ResolveEndpoint(channel, binding)); var ollama = new OllamaApiClient(httpClient, modelName); ollama.SelectedModel = modelName; var chat = new Chat(ollama); diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs index 559e495a..2a01ec23 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs @@ -192,19 +192,31 @@ public async IAsyncEnumerable ExecAsync( Message message, long ChatId, string modelName, LLMChannel channel, LlmExecutionContext executionContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + await foreach (var item in ExecAsync(message, ChatId, modelName, channel, null, executionContext, cancellationToken)) { + yield return item; + } + } + + public async IAsyncEnumerable ExecAsync( + Message message, long ChatId, string modelName, LLMChannel channel, + LLMApiBinding binding, + LlmExecutionContext executionContext, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(modelName)) modelName = Env.OpenAIModelName; if (string.IsNullOrWhiteSpace(modelName)) { _logger.LogError("{ServiceName}: Model name is not configured.", ServiceName); yield return $"Error: {ServiceName} model name is not configured."; yield break; } - if (channel == null || string.IsNullOrWhiteSpace(channel.Gateway) || string.IsNullOrWhiteSpace(channel.ApiKey)) { + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + var apiKey = LlmBindingSupport.ResolveApiKey(channel, binding); + if (channel == null || string.IsNullOrWhiteSpace(endpoint) || (binding?.AuthProfile != LlmAuthProfile.None && string.IsNullOrWhiteSpace(apiKey))) { _logger.LogError("{ServiceName}: Channel, Gateway, or ApiKey is not configured.", ServiceName); yield return $"Error: {ServiceName} channel/gateway/apikey is not configured."; yield break; } - await foreach (var item in ExecWithResponsesApiAsync(message, ChatId, modelName, channel, executionContext, cancellationToken)) { + await foreach (var item in ExecWithResponsesApiAsync(message, ChatId, modelName, channel, binding, executionContext, cancellationToken)) { yield return item; } } @@ -215,6 +227,7 @@ public async IAsyncEnumerable ExecAsync( private async IAsyncEnumerable ExecWithResponsesApiAsync( Message message, long ChatId, string modelName, LLMChannel channel, + LLMApiBinding binding, LlmExecutionContext executionContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { using var chatContentLogScope = LoggerHolders.PushChatContentLogScope(); @@ -254,10 +267,10 @@ private async IAsyncEnumerable ExecWithResponsesApiAsync( // --- Create Responses client --- using var httpClient = _httpClientFactory.CreateClient(); var clientOptions = new OpenAIClientOptions { - Endpoint = new Uri(channel.Gateway), + Endpoint = new Uri(LlmBindingSupport.ResolveEndpoint(channel, binding)), Transport = new HttpClientPipelineTransport(httpClient), }; - var apiKey = new ApiKeyCredential(channel.ApiKey); + var apiKey = new ApiKeyCredential(LlmBindingSupport.ResolveApiKey(channel, binding)); var responsesClient = new ResponsesClient(apiKey, clientOptions); // --- Tool call loop --- @@ -271,6 +284,7 @@ private async IAsyncEnumerable ExecWithResponsesApiAsync( var options = new CreateResponseOptions { Model = modelName, Instructions = instructions, + StreamingEnabled = true, }; var cacheKeyAttached = false; if (promptCachingEnabled) { @@ -471,12 +485,24 @@ public async IAsyncEnumerable ResumeFromSnapshotAsync( LlmContinuationSnapshot snapshot, LLMChannel channel, LlmExecutionContext executionContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + await foreach (var item in ResumeFromSnapshotAsync(snapshot, channel, null, executionContext, cancellationToken)) { + yield return item; + } + } + + public async IAsyncEnumerable ResumeFromSnapshotAsync( + LlmContinuationSnapshot snapshot, LLMChannel channel, + LLMApiBinding binding, + LlmExecutionContext executionContext, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { using var chatContentLogScope = LoggerHolders.PushChatContentLogScope(); if (snapshot == null) { _logger.LogError("{ServiceName}: Cannot resume from null snapshot.", ServiceName); yield break; } - if (channel == null || string.IsNullOrWhiteSpace(channel.Gateway) || string.IsNullOrWhiteSpace(channel.ApiKey)) { + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + var resolvedApiKey = LlmBindingSupport.ResolveApiKey(channel, binding); + if (channel == null || string.IsNullOrWhiteSpace(endpoint) || (binding?.AuthProfile != LlmAuthProfile.None && string.IsNullOrWhiteSpace(resolvedApiKey))) { _logger.LogError("{ServiceName}: Channel, Gateway, or ApiKey is not configured for resume.", ServiceName); yield break; } @@ -518,10 +544,10 @@ public async IAsyncEnumerable ResumeFromSnapshotAsync( using var httpClient = _httpClientFactory.CreateClient(); var clientOptions = new OpenAIClientOptions { - Endpoint = new Uri(channel.Gateway), + Endpoint = new Uri(LlmBindingSupport.ResolveEndpoint(channel, binding)), Transport = new HttpClientPipelineTransport(httpClient), }; - var apiKey = new ApiKeyCredential(channel.ApiKey); + var apiKey = new ApiKeyCredential(LlmBindingSupport.ResolveApiKey(channel, binding)); var responsesClient = new ResponsesClient(apiKey, clientOptions); var fullContentBuilder = new StringBuilder(snapshot.LastAccumulatedContent ?? ""); @@ -536,6 +562,7 @@ public async IAsyncEnumerable ResumeFromSnapshotAsync( var options = new CreateResponseOptions { Model = modelName, Instructions = instructions, + StreamingEnabled = true, }; var cacheKeyAttached = false; if (promptCachingEnabled) { @@ -722,12 +749,16 @@ public async IAsyncEnumerable ResumeFromSnapshotAsync( // ======================================================================== public async Task GenerateEmbeddingsAsync(string text, string modelName, LLMChannel channel) { + return await GenerateEmbeddingsAsync(text, modelName, channel, null); + } + + public async Task GenerateEmbeddingsAsync(string text, string modelName, LLMChannel channel, LLMApiBinding binding) { using var httpClient = _httpClientFactory.CreateClient(); var clientOptions = new OpenAIClientOptions { - Endpoint = new Uri(channel.Gateway), + Endpoint = new Uri(LlmBindingSupport.ResolveEndpoint(channel, binding)), Transport = new HttpClientPipelineTransport(httpClient), }; - var apiKey = new ApiKeyCredential(channel.ApiKey); + var apiKey = new ApiKeyCredential(LlmBindingSupport.ResolveApiKey(channel, binding)); OpenAIClient client = new(apiKey, clientOptions); try { @@ -813,6 +844,41 @@ public async Task> GetAllModels(LLMChannel channel) { } } + public async Task> GetAllModels(LLMChannel channel, LLMApiBinding binding) { + if (channel == null) return new List(); + if (binding == null) return await GetAllModels(channel); + if (channel.Provider == LLMProvider.Ollama) { + return new List(); + } + + // binding 路由:确定性 endpoint,不做品牌/URL 猜测(blueprint §六.7) + try { + var handler = new HttpClientHandler { + Proxy = WebRequest.DefaultWebProxy, + UseProxy = true + }; + using var httpClient = new HttpClient(handler); + + var clientOptions = new OpenAIClientOptions { + Endpoint = new Uri(LlmBindingSupport.ResolveEndpoint(channel, binding)), + Transport = new HttpClientPipelineTransport(httpClient), + }; + var apiKey = new ApiKeyCredential(LlmBindingSupport.ResolveApiKey(channel, binding)); + OpenAIClient client = new(apiKey, clientOptions); + var model = client.GetOpenAIModelClient(); + var models = await model.GetModelsAsync(); + return models.Value.Select(s => s.Id); + } catch (Exception ex) { + _logger.LogError(ex, "Error getting OpenAI model list (Gateway: {Gateway})", LlmBindingSupport.ResolveEndpoint(channel, binding)); + return new List(); + } + } + + public async Task IsHealthyAsync(LLMChannel channel, LLMApiBinding binding) { + var models = await GetAllModels(channel, binding); + return models.Any(); + } + public async Task> GetAllModelsWithCapabilities(LLMChannel channel) { using var httpClient = _httpClientFactory.CreateClient(); @@ -849,12 +915,18 @@ public async Task> GetAllModelsWithCapabiliti // ======================================================================== public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, string prompt = null) { + return await AnalyzeImageAsync(photoPath, modelName, channel, null, prompt); + } + + public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, LLMApiBinding binding, string prompt = null) { if (string.IsNullOrWhiteSpace(modelName)) { modelName = "gpt-4o"; } prompt = string.IsNullOrWhiteSpace(prompt) ? GeneralLLMService.DefaultAltPhotoPrompt : prompt; - if (channel == null || string.IsNullOrWhiteSpace(channel.Gateway) || string.IsNullOrWhiteSpace(channel.ApiKey)) { + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + var apiKey = LlmBindingSupport.ResolveApiKey(channel, binding); + if (channel == null || string.IsNullOrWhiteSpace(endpoint) || (binding?.AuthProfile != LlmAuthProfile.None && string.IsNullOrWhiteSpace(apiKey))) { _logger.LogError("{ServiceName}: Channel, Gateway or ApiKey is not configured.", ServiceName); return $"Error: {ServiceName} channel/gateway/apikey is not configured."; } @@ -863,10 +935,10 @@ public async Task AnalyzeImageAsync(string photoPath, string modelName, // For image analysis, use Chat Completions API (vision support is more mature) var clientOptions = new OpenAIClientOptions { - Endpoint = new Uri(channel.Gateway), + Endpoint = new Uri(LlmBindingSupport.ResolveEndpoint(channel, binding)), Transport = new HttpClientPipelineTransport(httpClient), }; - var chatClient = new ChatClient(model: modelName, credential: new(channel.ApiKey), clientOptions); + var chatClient = new ChatClient(model: modelName, credential: new ApiKeyCredential(LlmBindingSupport.ResolveApiKey(channel, binding)), clientOptions); try { using var fileStream = File.OpenRead(photoPath); diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs index cf09336e..1a68ae39 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs @@ -85,8 +85,9 @@ internal static bool IsMiniMaxCompatibleEndpoint(LLMChannel channel, string mode model.Contains("minimax", StringComparison.OrdinalIgnoreCase); } - internal static string NormalizeOpenAIEndpoint(LLMChannel channel) { - var gateway = channel?.Gateway ?? string.Empty; + internal static string NormalizeOpenAIEndpoint(LLMChannel channel, string endpoint = null) { + // endpoint 为 binding 解析后的地址(无 binding 时回退 channel.Gateway,blueprint §六.1) + var gateway = endpoint ?? channel?.Gateway ?? string.Empty; if (channel?.Provider != LLMProvider.MiniMax) { return gateway; } @@ -299,18 +300,61 @@ public virtual async Task> GetAllModels(LLMChannel channel) } } + public virtual async Task> GetAllModels(LLMChannel channel, LLMApiBinding binding) { + if (channel == null) return new List(); + if (binding == null) return await GetAllModels(channel); + + // binding 路由:确定性 endpoint,不做品牌/URL 猜测(blueprint §六.7) + var genericModels = await GetGenericOpenAICompatibleModels(channel, binding); + if (genericModels.Any()) { + return genericModels; + } + + try { + var handler = new HttpClientHandler { + Proxy = WebRequest.DefaultWebProxy, + UseProxy = true + }; + + using var httpClient = new HttpClient(handler); + + // --- Client Setup --- + var clientOptions = new OpenAIClientOptions { + Endpoint = new Uri(LlmBindingSupport.ResolveEndpoint(channel, binding)), + Transport = new HttpClientPipelineTransport(httpClient), + }; + + var apikey = new ApiKeyCredential(LlmBindingSupport.ResolveApiKey(channel, binding)); + + OpenAIClient client = new(apikey, clientOptions); + var model = client.GetOpenAIModelClient(); + var models = await model.GetModelsAsync(); + return from s in models.Value + select s.Id; + } catch (Exception ex) { + _logger.LogError(ex, "使用 OpenAI SDK 获取模型列表失败 (Gateway: {Gateway})", LlmBindingSupport.ResolveEndpoint(channel, binding)); + return new List(); + } + } + + public async Task IsHealthyAsync(LLMChannel channel, LLMApiBinding binding) { + var models = await GetAllModels(channel, binding); + return models.Any(); + } + /// /// 使用通用 HTTP GET 方式获取 OpenAI 兼容 API 的模型列表,兼容 MiniMax、DeepSeek 等提供商 /// - private async Task> GetGenericOpenAICompatibleModels(LLMChannel channel) { + private async Task> GetGenericOpenAICompatibleModels(LLMChannel channel, LLMApiBinding? binding = null) { try { var httpClient = _httpClientFactory.CreateClient(); - if (!string.IsNullOrEmpty(channel.ApiKey)) { - httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {channel.ApiKey}"); + var apiKey = LlmBindingSupport.ResolveApiKey(channel, binding); + if (!string.IsNullOrEmpty(apiKey)) { + httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); } // 构建模型列表 URL,确保路径正确 - var gatewayBase = NormalizeOpenAIEndpoint(channel); + var gatewayBase = NormalizeOpenAIEndpoint(channel, LlmBindingSupport.ResolveEndpoint(channel, binding)).TrimEnd('/'); var modelsUrl = gatewayBase.EndsWith("/v1", StringComparison.OrdinalIgnoreCase) ? $"{gatewayBase}/models" : $"{gatewayBase}/v1/models"; @@ -1040,6 +1084,15 @@ public async IAsyncEnumerable ExecAsync(Model.Data.Message message, long public async IAsyncEnumerable ExecAsync(Model.Data.Message message, long ChatId, string modelName, LLMChannel channel, LlmExecutionContext executionContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + await foreach (var item in ExecAsync(message, ChatId, modelName, channel, null, executionContext, cancellationToken)) { + yield return item; + } + } + + public async IAsyncEnumerable ExecAsync(Model.Data.Message message, long ChatId, string modelName, LLMChannel channel, + LLMApiBinding binding, + LlmExecutionContext executionContext, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(modelName)) modelName = Env.OpenAIModelName; if (string.IsNullOrWhiteSpace(modelName)) { @@ -1047,7 +1100,9 @@ public async IAsyncEnumerable ExecAsync(Model.Data.Message message, long yield return $"Error: {ServiceName} model name is not configured."; yield break; } - if (channel == null || string.IsNullOrWhiteSpace(channel.Gateway) || string.IsNullOrWhiteSpace(channel.ApiKey)) { + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + var apiKey = LlmBindingSupport.ResolveApiKey(channel, binding); + if (channel == null || string.IsNullOrWhiteSpace(endpoint) || (binding?.AuthProfile != LlmAuthProfile.None && string.IsNullOrWhiteSpace(apiKey))) { _logger.LogError("{ServiceName}: Channel, Gateway, or ApiKey is not configured.", ServiceName); yield return $"Error: {ServiceName} channel/gateway/apikey is not configured."; yield break; @@ -1061,7 +1116,7 @@ public async IAsyncEnumerable ExecAsync(Model.Data.Message message, long useNativeToolCalling = false; } - var isMiniMaxCompatibleEndpoint = IsMiniMaxCompatibleEndpoint(channel, modelName); + var isMiniMaxCompatibleEndpoint = binding == null && IsMiniMaxCompatibleEndpoint(channel, modelName); _logger.LogInformation( "{ServiceName}: Tool calling setup for model {Model}. UseNative={UseNative}, NativeToolCount={NativeToolCount}, Provider={Provider}, Gateway={Gateway}, IsMiniMaxCompatible={IsMiniMaxCompatible}", ServiceName, @@ -1074,7 +1129,7 @@ public async IAsyncEnumerable ExecAsync(Model.Data.Message message, long if (useNativeToolCalling) { bool nativeFailed = false; - var nativeEnumerator = ExecWithNativeToolCallingAsync(message, ChatId, modelName, channel, executionContext, nativeTools, cancellationToken); + var nativeEnumerator = ExecWithNativeToolCallingAsync(message, ChatId, modelName, channel, binding, executionContext, nativeTools, cancellationToken); await using var enumerator = nativeEnumerator.GetAsyncEnumerator(cancellationToken); bool hasFirst = false; try { @@ -1096,7 +1151,7 @@ public async IAsyncEnumerable ExecAsync(Model.Data.Message message, long } // Fallback: XML prompt-based tool calling - await foreach (var item in ExecWithXmlToolCallingAsync(message, ChatId, modelName, channel, executionContext, cancellationToken)) { + await foreach (var item in ExecWithXmlToolCallingAsync(message, ChatId, modelName, channel, binding, executionContext, cancellationToken)) { yield return item; } } @@ -1144,6 +1199,7 @@ private async Task CheckVisionSupport(string modelName, int channelId) { /// private async IAsyncEnumerable ExecWithNativeToolCallingAsync( Model.Data.Message message, long ChatId, string modelName, LLMChannel channel, + LLMApiBinding binding, LlmExecutionContext executionContext, List nativeTools, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { @@ -1166,10 +1222,10 @@ private async IAsyncEnumerable ExecWithNativeToolCallingAsync( using var client = _httpClientFactory.CreateClient(); var clientOptions = new OpenAIClientOptions { - Endpoint = new Uri(NormalizeOpenAIEndpoint(channel)), + Endpoint = new Uri(NormalizeOpenAIEndpoint(channel, LlmBindingSupport.ResolveEndpoint(channel, binding))), Transport = new HttpClientPipelineTransport(client), }; - var chatClient = new ChatClient(model: modelName, credential: new(channel.ApiKey), clientOptions); + var chatClient = new ChatClient(model: modelName, credential: new ApiKeyCredential(LlmBindingSupport.ResolveApiKey(channel, binding)), clientOptions); var completionOptions = new ChatCompletionOptions(); foreach (var tool in nativeTools) { @@ -1187,7 +1243,7 @@ private async IAsyncEnumerable ExecWithNativeToolCallingAsync( modelName, nativeTools.Count, string.Join(",", nativeTools.Select(t => t.FunctionName).Take(80))); - var includeEmptyReasoningContent = ShouldIncludeEmptyReasoningContent(channel, modelName); + var includeEmptyReasoningContent = binding == null && ShouldIncludeEmptyReasoningContent(channel, modelName); try { int maxToolCycles = Env.MaxToolCycles; @@ -1439,6 +1495,7 @@ private async IAsyncEnumerable ExecWithNativeToolCallingAsync( /// private async IAsyncEnumerable ExecWithXmlToolCallingAsync( Model.Data.Message message, long ChatId, string modelName, LLMChannel channel, + LLMApiBinding binding, LlmExecutionContext executionContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { using var chatContentLogScope = LoggerHolders.PushChatContentLogScope(); @@ -1460,10 +1517,10 @@ private async IAsyncEnumerable ExecWithXmlToolCallingAsync( using var client = _httpClientFactory.CreateClient(); var clientOptions = new OpenAIClientOptions { - Endpoint = new Uri(NormalizeOpenAIEndpoint(channel)), + Endpoint = new Uri(NormalizeOpenAIEndpoint(channel, LlmBindingSupport.ResolveEndpoint(channel, binding))), Transport = new HttpClientPipelineTransport(client), }; - var chatClient = new ChatClient(model: modelName, credential: new(channel.ApiKey), clientOptions); + var chatClient = new ChatClient(model: modelName, credential: new ApiKeyCredential(LlmBindingSupport.ResolveApiKey(channel, binding)), clientOptions); var completionOptions = new ChatCompletionOptions(); var cacheKeyAttached = false; if (promptCachingEnabled) { @@ -1600,12 +1657,23 @@ private async IAsyncEnumerable ExecWithXmlToolCallingAsync( public async IAsyncEnumerable ResumeFromSnapshotAsync(LlmContinuationSnapshot snapshot, LLMChannel channel, LlmExecutionContext executionContext, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + await foreach (var item in ResumeFromSnapshotAsync(snapshot, channel, null, executionContext, cancellationToken)) { + yield return item; + } + } + + public async IAsyncEnumerable ResumeFromSnapshotAsync(LlmContinuationSnapshot snapshot, LLMChannel channel, + LLMApiBinding binding, + LlmExecutionContext executionContext, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { using var chatContentLogScope = LoggerHolders.PushChatContentLogScope(); if (snapshot == null) { _logger.LogError("{ServiceName}: Cannot resume from null snapshot.", ServiceName); yield break; } - if (channel == null || string.IsNullOrWhiteSpace(channel.Gateway) || string.IsNullOrWhiteSpace(channel.ApiKey)) { + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + var apiKey = LlmBindingSupport.ResolveApiKey(channel, binding); + if (channel == null || string.IsNullOrWhiteSpace(endpoint) || (binding?.AuthProfile != LlmAuthProfile.None && string.IsNullOrWhiteSpace(apiKey))) { _logger.LogError("{ServiceName}: Channel, Gateway, or ApiKey is not configured for resume.", ServiceName); yield break; } @@ -1616,7 +1684,7 @@ public async IAsyncEnumerable ResumeFromSnapshotAsync(LlmContinuationSna _logger.LogInformation("{ServiceName}: Resuming from snapshot {SnapshotId} for ChatId {ChatId}, restoring {HistoryCount} history entries.", ServiceName, snapshot.SnapshotId, snapshot.ChatId, snapshot.ProviderHistory?.Count ?? 0); - var includeEmptyReasoningContent = ShouldIncludeEmptyReasoningContent(channel, modelName); + var includeEmptyReasoningContent = binding == null && ShouldIncludeEmptyReasoningContent(channel, modelName); List providerHistory = DeserializeProviderHistory(snapshot.ProviderHistory, includeEmptyReasoningContent); var shouldObservePromptCaching = channel.Provider == LLMProvider.OpenAI; var promptCachingEnabled = shouldObservePromptCaching && await IsPromptCachingEnabledAsync(); @@ -1629,10 +1697,10 @@ public async IAsyncEnumerable ResumeFromSnapshotAsync(LlmContinuationSna using var client = _httpClientFactory.CreateClient(); var clientOptions = new OpenAIClientOptions { - Endpoint = new Uri(NormalizeOpenAIEndpoint(channel)), + Endpoint = new Uri(NormalizeOpenAIEndpoint(channel, LlmBindingSupport.ResolveEndpoint(channel, binding))), Transport = new HttpClientPipelineTransport(client), }; - var chatClient = new ChatClient(model: modelName, credential: new(channel.ApiKey), clientOptions); + var chatClient = new ChatClient(model: modelName, credential: new ApiKeyCredential(LlmBindingSupport.ResolveApiKey(channel, binding)), clientOptions); var completionOptions = new ChatCompletionOptions(); var cacheKeyAttached = false; if (promptCachingEnabled) { @@ -1925,16 +1993,20 @@ private static void SetAssistantReasoningContent(AssistantChatMessage msg, strin } public async Task GenerateEmbeddingsAsync(string text, string modelName, LLMChannel channel) { + return await GenerateEmbeddingsAsync(text, modelName, channel, null); + } + + public async Task GenerateEmbeddingsAsync(string text, string modelName, LLMChannel channel, LLMApiBinding binding) { using var httpClient = _httpClientFactory.CreateClient(); var clientOptions = new OpenAIClientOptions { - Endpoint = new Uri(NormalizeOpenAIEndpoint(channel)), + Endpoint = new Uri(NormalizeOpenAIEndpoint(channel, LlmBindingSupport.ResolveEndpoint(channel, binding))), Transport = new HttpClientPipelineTransport(httpClient), }; - var apikey = new ApiKeyCredential(channel.ApiKey); + var apikey = new ApiKeyCredential(LlmBindingSupport.ResolveApiKey(channel, binding)); OpenAIClient client = new(apikey, clientOptions); try { @@ -2023,13 +2095,19 @@ public async Task GetModel(long ChatId) { } public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, string prompt = null) { + return await AnalyzeImageAsync(photoPath, modelName, channel, null, prompt); + } + + public async Task AnalyzeImageAsync(string photoPath, string modelName, LLMChannel channel, LLMApiBinding binding, string prompt = null) { if (string.IsNullOrWhiteSpace(modelName)) { modelName = "gpt-4-vision-preview"; } prompt = string.IsNullOrWhiteSpace(prompt) ? GeneralLLMService.DefaultAltPhotoPrompt : prompt; - if (channel == null || string.IsNullOrWhiteSpace(channel.Gateway) || string.IsNullOrWhiteSpace(channel.ApiKey)) { + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + var apiKey = LlmBindingSupport.ResolveApiKey(channel, binding); + if (channel == null || string.IsNullOrWhiteSpace(endpoint) || (binding?.AuthProfile != LlmAuthProfile.None && string.IsNullOrWhiteSpace(apiKey))) { _logger.LogError("{ServiceName}: Channel, Gateway or ApiKey is not configured.", ServiceName); return $"Error: {ServiceName} channel/gateway/apikey is not configured."; } @@ -2037,11 +2115,11 @@ public async Task AnalyzeImageAsync(string photoPath, string modelName, using var httpClient = _httpClientFactory.CreateClient(); var clientOptions = new OpenAIClientOptions { - Endpoint = new Uri(NormalizeOpenAIEndpoint(channel)), + Endpoint = new Uri(NormalizeOpenAIEndpoint(channel, LlmBindingSupport.ResolveEndpoint(channel, binding))), Transport = new HttpClientPipelineTransport(httpClient), }; - var chatClient = new ChatClient(model: modelName, credential: new(channel.ApiKey), clientOptions); + var chatClient = new ChatClient(model: modelName, credential: new ApiKeyCredential(LlmBindingSupport.ResolveApiKey(channel, binding)), clientOptions); try { // 读取图像并转换为Base64 diff --git a/TelegramSearchBot.LLMAgent/Service/LlmServiceProxy.cs b/TelegramSearchBot.LLMAgent/Service/LlmServiceProxy.cs index c94b1098..eeeb21ee 100644 --- a/TelegramSearchBot.LLMAgent/Service/LlmServiceProxy.cs +++ b/TelegramSearchBot.LLMAgent/Service/LlmServiceProxy.cs @@ -24,12 +24,13 @@ public async IAsyncEnumerable CallAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { await SeedTaskDataAsync(task, cancellationToken); - var service = ResolveService(task.Channel.Provider); + var binding = ToBinding(task.Channel); + var service = binding != null ? ResolveService(binding.Protocol) : ResolveService(task.Channel.Provider); ApplyBotIdentity(task.BotName, task.BotUserId); var channel = ToEntity(task.Channel); if (task.Kind == AgentTaskKind.Continuation && task.ContinuationSnapshot != null) { - await foreach (var chunk in service.ResumeFromSnapshotAsync(task.ContinuationSnapshot, channel, executionContext, cancellationToken) + await foreach (var chunk in service.ResumeFromSnapshotAsync(task.ContinuationSnapshot, channel, binding, executionContext, cancellationToken) .WithCancellation(cancellationToken)) { yield return chunk; } @@ -47,12 +48,30 @@ public async IAsyncEnumerable CallAsync( DateTime = task.CreatedAtUtc }; - await foreach (var chunk in service.ExecAsync(message, task.ChatId, task.ModelName, channel, executionContext, cancellationToken) + await foreach (var chunk in service.ExecAsync(message, task.ChatId, task.ModelName, channel, binding, executionContext, cancellationToken) .WithCancellation(cancellationToken)) { yield return chunk; } } + /// + /// 从 config 还原 binding(Agent 进程内 transient 实体,不入库)。 + /// 旧 config / legacy 路由无 binding 字段时返回 null,严格走 Provider/Gateway 路径。 + /// + private static LLMApiBinding? ToBinding(AgentChannelConfig config) { + if (!config.BindingId.HasValue || !config.BindingProtocol.HasValue || !config.BindingAuthProfile.HasValue) { + return null; + } + return new LLMApiBinding { + Id = config.BindingId.Value, + LLMChannelId = config.ChannelId, + Endpoint = config.BindingEndpoint, + Protocol = config.BindingProtocol.Value, + AuthProfile = config.BindingAuthProfile.Value, + IsDefault = false + }; + } + private ILLMService ResolveService(LLMProvider provider) { return provider switch { LLMProvider.Ollama => _serviceProvider.GetRequiredService(), @@ -63,6 +82,18 @@ private ILLMService ResolveService(LLMProvider provider) { }; } + /// 按 binding 线协议解析 client(与 ILLMFactory.GetLLMService(LlmProtocol) 同构)。 + private ILLMService ResolveService(LlmProtocol protocol) { + return protocol switch { + LlmProtocol.OpenAIChat => _serviceProvider.GetRequiredService(), + LlmProtocol.OpenAIResponses => _serviceProvider.GetRequiredService(), + LlmProtocol.AnthropicMessages => _serviceProvider.GetRequiredService(), + LlmProtocol.Ollama => _serviceProvider.GetRequiredService(), + LlmProtocol.Gemini => _serviceProvider.GetRequiredService(), + _ => _serviceProvider.GetRequiredService() + }; + } + private void ApplyBotIdentity(string botName, long botUserId) { var identityProvider = _serviceProvider.GetService(); if (identityProvider != null) { diff --git a/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs b/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs index 59b1ab8b..d700b851 100644 --- a/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs +++ b/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs @@ -25,6 +25,7 @@ public class EditLLMConfHelperTest { private readonly Mock _ollamaServiceMock; private readonly Mock _geminiServiceMock; private readonly Mock _modelCapabilityServiceMock; + private readonly Mock> _loggerMock; private readonly EditLLMConfHelper _helper; public EditLLMConfHelperTest() { @@ -84,13 +85,13 @@ public EditLLMConfHelperTest() { llmFactoryMock.Setup(f => f.GetLLMService(LLMProvider.Gemini)).Returns(_geminiServiceMock.Object); // 创建Logger mock - var loggerMock = new Mock>(); + _loggerMock = new Mock>(); _helper = new EditLLMConfHelper( _context, llmFactoryMock.Object, _modelCapabilityServiceMock.Object, - loggerMock.Object); + _loggerMock.Object); _messageExtensionServiceMock.Setup(m => m.AddOrUpdateAsync(It.IsAny())) .Returns(Task.CompletedTask); @@ -100,27 +101,37 @@ public EditLLMConfHelperTest() { [Fact] public async Task RefreshAllChannel_ShouldMarkDeletedModels_WhenModelDisappears() { - // Arrange: channel with 2 pre-existing models - var channel = new LLMChannel { Id = 10, Name = "OpenAI", Provider = LLMProvider.OpenAI }; + // Arrange: channel with default binding; a Discovered row whose model disappears + // from the catalog gets soft-deleted; a Manual row never does (blueprint §四.5) + var channel = new LLMChannel { Id = 10, Name = "OpenAI", Provider = LLMProvider.OpenAI, Gateway = "http://gw" }; await _context.LLMChannels.AddAsync(channel); + var binding = new LLMApiBinding { LLMChannelId = 10, Endpoint = "http://gw", Protocol = LlmProtocol.OpenAIChat, AuthProfile = LlmAuthProfile.Bearer, IsDefault = true }; + await _context.LLMApiBindings.AddAsync(binding); + await _context.SaveChangesAsync(); await _context.ChannelsWithModel.AddRangeAsync(new[] { - new ChannelWithModel { LLMChannelId = 10, ModelName = "openai-model1", IsDeleted = false }, - new ChannelWithModel { LLMChannelId = 10, ModelName = "old-model", IsDeleted = false } + new ChannelWithModel { LLMChannelId = 10, ModelName = "openai-model1", IsDeleted = false, AuthorizationSource = AuthorizationSource.Discovered, ApiBindingId = binding.Id }, + new ChannelWithModel { LLMChannelId = 10, ModelName = "old-model", IsDeleted = false, AuthorizationSource = AuthorizationSource.Discovered, ApiBindingId = binding.Id }, + new ChannelWithModel { LLMChannelId = 10, ModelName = "manual-model", IsDeleted = false, AuthorizationSource = AuthorizationSource.Manual } }); await _context.SaveChangesAsync(); - // Mock now only returns openai-model1 and openai-model2 (old-model disappeared) - // OpenAI mock already returns ["openai-model1", "openai-model2"] in constructor + // Mock returns ["openai-model1", "openai-model2"] (old-model + manual-model missing from catalog) // Act await _helper.RefreshAllChannel(); - // Assert: old-model should be marked as deleted + // Assert: Discovered row missing from catalog is soft-deleted var oldModel = await _context.ChannelsWithModel .FirstOrDefaultAsync(m => m.LLMChannelId == 10 && m.ModelName == "old-model"); Assert.NotNull(oldModel); Assert.True(oldModel.IsDeleted); + // Manual row is NEVER soft-deleted by refresh, even when missing from catalog + var manualModel = await _context.ChannelsWithModel + .FirstOrDefaultAsync(m => m.LLMChannelId == 10 && m.ModelName == "manual-model"); + Assert.NotNull(manualModel); + Assert.False(manualModel.IsDeleted); + // openai-model1 should still exist and not be deleted var model1 = await _context.ChannelsWithModel .FirstOrDefaultAsync(m => m.LLMChannelId == 10 && m.ModelName == "openai-model1"); @@ -130,13 +141,16 @@ await _context.ChannelsWithModel.AddRangeAsync(new[] { [Fact] public async Task RefreshAllChannel_ShouldRestoreModels_WhenModelReappears() { - // Arrange: channel with a previously-deleted model - var channel = new LLMChannel { Id = 11, Name = "OpenAI", Provider = LLMProvider.OpenAI }; + // Arrange: channel with default binding; a previously-deleted Discovered row is + // restored when its model reappears in the catalog; a deleted Manual row is never resurrected + var channel = new LLMChannel { Id = 11, Name = "OpenAI", Provider = LLMProvider.OpenAI, Gateway = "http://gw" }; await _context.LLMChannels.AddAsync(channel); - await _context.ChannelsWithModel.AddAsync(new ChannelWithModel { - LLMChannelId = 11, - ModelName = "openai-model1", - IsDeleted = true // Previously deleted + var binding = new LLMApiBinding { LLMChannelId = 11, Endpoint = "http://gw", Protocol = LlmProtocol.OpenAIChat, AuthProfile = LlmAuthProfile.Bearer, IsDefault = true }; + await _context.LLMApiBindings.AddAsync(binding); + await _context.SaveChangesAsync(); + await _context.ChannelsWithModel.AddRangeAsync(new[] { + new ChannelWithModel { LLMChannelId = 11, ModelName = "openai-model1", IsDeleted = true, AuthorizationSource = AuthorizationSource.Discovered, ApiBindingId = binding.Id }, + new ChannelWithModel { LLMChannelId = 11, ModelName = "manual-gone", IsDeleted = true, AuthorizationSource = AuthorizationSource.Manual } }); await _context.SaveChangesAsync(); @@ -145,17 +159,25 @@ await _context.ChannelsWithModel.AddAsync(new ChannelWithModel { // Act var count = await _helper.RefreshAllChannel(); - // Assert: openai-model1 should be restored (IsDeleted = false) + // Assert: Discovered row restored var model1 = await _context.ChannelsWithModel .FirstOrDefaultAsync(m => m.LLMChannelId == 11 && m.ModelName == "openai-model1"); Assert.NotNull(model1); Assert.False(model1.IsDeleted); - // openai-model2 should be newly added + // Manual row is NEVER resurrected by refresh + var manualGone = await _context.ChannelsWithModel + .FirstOrDefaultAsync(m => m.LLMChannelId == 11 && m.ModelName == "manual-gone"); + Assert.NotNull(manualGone); + Assert.True(manualGone.IsDeleted); + + // openai-model2 should be newly added as Discovered var model2 = await _context.ChannelsWithModel .FirstOrDefaultAsync(m => m.LLMChannelId == 11 && m.ModelName == "openai-model2"); Assert.NotNull(model2); Assert.False(model2.IsDeleted); + Assert.Equal(AuthorizationSource.Discovered, model2.AuthorizationSource); + Assert.Equal(binding.Id, model2.ApiBindingId); // count reflects restored + added Assert.Equal(2, count); // 1 restored + 1 added @@ -452,5 +474,288 @@ public async Task GetChannelsByName_ShouldReturnEmptyForNoMatches() { // Assert Assert.Empty(result); } + + // ===== Phase 3: Catalog != Entitlement (blueprint §四) ===== + + private async Task SeedChannelWithDefaultBinding(int id, string name, LLMProvider provider, + string gateway = "http://gw", string? endpoint = null) { + var channel = new LLMChannel { Id = id, Name = name, Provider = provider, Gateway = gateway }; + await _context.LLMChannels.AddAsync(channel); + await _context.SaveChangesAsync(); + var binding = new LLMApiBinding { + LLMChannelId = id, + Endpoint = endpoint ?? gateway, + Protocol = LlmProtocol.OpenAIChat, + AuthProfile = LlmAuthProfile.Bearer, + IsDefault = true + }; + await _context.LLMApiBindings.AddAsync(binding); + await _context.SaveChangesAsync(); + return binding; + } + + [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"; + var binding = await SeedChannelWithDefaultBinding(20, "OpenCode", LLMProvider.OpenAI, + gateway: openCodeEndpoint, endpoint: openCodeEndpoint); + await _context.ChannelsWithModel.AddRangeAsync(new[] { + new ChannelWithModel { LLMChannelId = 20, ModelName = "ghost-model", IsDeleted = false, AuthorizationSource = AuthorizationSource.Discovered, ApiBindingId = binding.Id }, + new ChannelWithModel { LLMChannelId = 20, ModelName = "gone-model", IsDeleted = true, AuthorizationSource = AuthorizationSource.Discovered, ApiBindingId = binding.Id }, + new ChannelWithModel { LLMChannelId = 20, ModelName = "manual-active", IsDeleted = false, AuthorizationSource = AuthorizationSource.Manual }, + new ChannelWithModel { LLMChannelId = 20, ModelName = "manual-gone", IsDeleted = true, AuthorizationSource = AuthorizationSource.Manual } + }); + await _context.SaveChangesAsync(); + + // Act(OpenCode 门禁在取目录前短路,即使 mock 返回模型也不产生任何行) + var count = await _helper.RefreshAllChannel(); + + // Assert:无新增、无软删、无复活 + Assert.Equal(0, count); + var rows = await _context.ChannelsWithModel.Where(m => m.LLMChannelId == 20).ToListAsync(); + Assert.Equal(4, rows.Count); + Assert.False(rows.Single(r => r.ModelName == "ghost-model").IsDeleted); + Assert.True(rows.Single(r => r.ModelName == "gone-model").IsDeleted); + Assert.False(rows.Single(r => r.ModelName == "manual-active").IsDeleted); + Assert.True(rows.Single(r => r.ModelName == "manual-gone").IsDeleted); + Assert.DoesNotContain(rows, r => r.ModelName == "openai-model1"); + } + + [Fact] + public async Task RefreshAllChannel_FetchFailure_ManualAndDiscoveredRowsUntouched() { + // Arrange: 目录抓取失败 → 整 channel 跳过,不产生任何创建/软删 + var binding = await SeedChannelWithDefaultBinding(21, "OpenAI", LLMProvider.OpenAI); + await _context.ChannelsWithModel.AddRangeAsync(new[] { + new ChannelWithModel { LLMChannelId = 21, ModelName = "manual-active", IsDeleted = false, AuthorizationSource = AuthorizationSource.Manual }, + new ChannelWithModel { LLMChannelId = 21, ModelName = "disc-active", IsDeleted = false, AuthorizationSource = AuthorizationSource.Discovered, ApiBindingId = binding.Id } + }); + await _context.SaveChangesAsync(); + + _openAIServiceMock.Setup(s => s.GetAllModels(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("upstream down")); + + // Act + var count = await _helper.RefreshAllChannel(); + + // Assert + Assert.Equal(0, count); + var rows = await _context.ChannelsWithModel.Where(m => m.LLMChannelId == 21).ToListAsync(); + Assert.Equal(2, rows.Count); + Assert.False(rows.Single(r => r.ModelName == "manual-active").IsDeleted); + Assert.False(rows.Single(r => r.ModelName == "disc-active").IsDeleted); + + // 恢复 mock 供后续测试使用 + _openAIServiceMock.Setup(s => s.GetAllModels(It.IsAny())) + .ReturnsAsync(new List { "openai-model1", "openai-model2" }); + } + + [Fact] + public async Task RefreshAllChannel_ManualRowsNeverSoftDeleted_OnCatalogMissingItems() { + // Arrange: 目录缺项时 Manual 行永不软删,同 channel 的 Discovered 行照常软删 + var binding = await SeedChannelWithDefaultBinding(22, "OpenAI", LLMProvider.OpenAI); + await _context.ChannelsWithModel.AddRangeAsync(new[] { + new ChannelWithModel { LLMChannelId = 22, ModelName = "manual-ghost", IsDeleted = false, AuthorizationSource = AuthorizationSource.Manual }, + new ChannelWithModel { LLMChannelId = 22, ModelName = "disc-ghost", IsDeleted = false, AuthorizationSource = AuthorizationSource.Discovered, ApiBindingId = binding.Id } + }); + await _context.SaveChangesAsync(); + + // Act(mock 目录只含 openai-model1/openai-model2,两个 ghost 都不在其中) + await _helper.RefreshAllChannel(); + + // Assert + var manualGhost = await _context.ChannelsWithModel.FirstAsync(m => m.LLMChannelId == 22 && m.ModelName == "manual-ghost"); + Assert.False(manualGhost.IsDeleted); + var discGhost = await _context.ChannelsWithModel.FirstAsync(m => m.LLMChannelId == 22 && m.ModelName == "disc-ghost"); + Assert.True(discGhost.IsDeleted); + } + + [Fact] + public async Task RefreshAllChannel_CaseInsensitiveDedup_NoDuplicate() { + // Arrange: 已有 Discovered 行 gpt-4o,目录返回 GPT-4O(大小写不同)→ 合并,不新建重复行 + var binding = await SeedChannelWithDefaultBinding(23, "OpenAI", LLMProvider.OpenAI); + await _context.ChannelsWithModel.AddAsync(new ChannelWithModel { + LLMChannelId = 23, ModelName = "gpt-4o", IsDeleted = false, + AuthorizationSource = AuthorizationSource.Discovered, ApiBindingId = binding.Id + }); + await _context.SaveChangesAsync(); + + _openAIServiceMock.Setup(s => s.GetAllModels(It.IsAny())) + .ReturnsAsync(new List { "GPT-4O", "new-model" }); + + // Act + await _helper.RefreshAllChannel(); + + // Assert:gpt-4o 只有一行且未删除;new-model 以 Discovered 新建 + var gptRows = await _context.ChannelsWithModel.Where(m => m.LLMChannelId == 23 && m.ModelName == "gpt-4o").ToListAsync(); + Assert.Single(gptRows); + Assert.False(gptRows[0].IsDeleted); + var newModel = await _context.ChannelsWithModel.FirstAsync(m => m.LLMChannelId == 23 && m.ModelName == "new-model"); + Assert.Equal(AuthorizationSource.Discovered, newModel.AuthorizationSource); + Assert.Equal(binding.Id, newModel.ApiBindingId); + + _openAIServiceMock.Setup(s => s.GetAllModels(It.IsAny())) + .ReturnsAsync(new List { "openai-model1", "openai-model2" }); + } + + [Fact] + public async Task RefreshAllChannel_ChannelWithoutDefaultBinding_CreatesExactlyOne() { + // Arrange: 无任何 binding 的 legacy 渠道(如旧二进制新建),刷新时按 Provider/Gateway 补建恰好一个默认 binding + var channel = new LLMChannel { Id = 24, Name = "Claude", Provider = LLMProvider.Anthropic, Gateway = "https://api.anthropic.com" }; + await _context.LLMChannels.AddAsync(channel); + await _context.SaveChangesAsync(); + + // Act(Anthropic 未注册到 factory mock,服务为 null 也先完成 repair) + await _helper.RefreshAllChannel(); + + // Assert:恰好一个默认 binding,映射与迁移一致 + var bindings = await _context.LLMApiBindings.Where(b => b.LLMChannelId == 24).ToListAsync(); + Assert.Single(bindings); + Assert.True(bindings[0].IsDefault); + Assert.Equal("https://api.anthropic.com", bindings[0].Endpoint); + Assert.Equal(LlmProtocol.AnthropicMessages, bindings[0].Protocol); + Assert.Equal(LlmAuthProfile.AnthropicApiKey, bindings[0].AuthProfile); + } + + [Fact] + public async Task AddModelWithChannel_CaseInsensitiveMerges() { + // Arrange: 已存在 gpt-4o,管理员添加 GPT-4O → 合并到同一行,不重复(blueprint §七.6) + var binding = await SeedChannelWithDefaultBinding(25, "OpenAI", LLMProvider.OpenAI); + await _context.ChannelsWithModel.AddAsync(new ChannelWithModel { + LLMChannelId = 25, ModelName = "gpt-4o", IsDeleted = false, + AuthorizationSource = AuthorizationSource.Manual, ApiBindingId = binding.Id + }); + await _context.SaveChangesAsync(); + + // Act + var result = await _helper.AddModelWithChannel(25, new List { "GPT-4O" }); + + // Assert + Assert.True(result); + var rows = await _context.ChannelsWithModel.Where(m => m.LLMChannelId == 25).ToListAsync(); + Assert.Single(rows); + Assert.False(rows[0].IsDeleted); + } + + [Fact] + public async Task GetModelsByChannelId_MultiBindingShowsSuffix_SingleBindingPlain() { + // Arrange: 同一模型跨两个 binding → `model [channel/binding/protocol]`;单 binding 模型保持原名 + var binding1 = await SeedChannelWithDefaultBinding(26, "Test Chan", LLMProvider.OpenAI); + var binding2 = new LLMApiBinding { LLMChannelId = 26, Endpoint = "http://gw2", Protocol = LlmProtocol.OpenAIResponses, AuthProfile = LlmAuthProfile.Bearer, IsDefault = false }; + await _context.LLMApiBindings.AddAsync(binding2); + await _context.SaveChangesAsync(); + await _context.ChannelsWithModel.AddRangeAsync(new[] { + new ChannelWithModel { LLMChannelId = 26, ModelName = "gpt-4o", IsDeleted = false, ApiBindingId = binding1.Id }, + new ChannelWithModel { LLMChannelId = 26, ModelName = "gpt-4o", IsDeleted = false, ApiBindingId = binding2.Id }, + new ChannelWithModel { LLMChannelId = 26, ModelName = "single-model", IsDeleted = false, ApiBindingId = binding1.Id } + }); + await _context.SaveChangesAsync(); + + // Act + var models = await _helper.GetModelsByChannelId(26); + + // Assert + Assert.Equal(3, models.Count); + Assert.Contains($"gpt-4o [Test Chan/{binding1.Id}/OpenAIChat]", models); + Assert.Contains($"gpt-4o [Test Chan/{binding2.Id}/OpenAIResponses]", models); + Assert.Contains("single-model", models); + } + + [Fact] + public async Task SetDefaultBinding_CreatesDefaultAndMirrorsChannel() { + // Arrange: 无 binding 的渠道 + var channel = new LLMChannel { Id = 27, Name = "New", Provider = LLMProvider.OpenAI, Gateway = "http://old" }; + await _context.LLMChannels.AddAsync(channel); + await _context.SaveChangesAsync(); + + // Act + var ok = await _helper.SetDefaultBinding(27, "https://new.endpoint/v1", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer); + + // Assert:创建默认 binding + 镜像 Gateway/Provider(旧二进制继续可用,blueprint §七) + Assert.True(ok); + var binding = await _context.LLMApiBindings.SingleAsync(b => b.LLMChannelId == 27); + Assert.True(binding.IsDefault); + Assert.Equal("https://new.endpoint/v1", binding.Endpoint); + Assert.Equal(LlmProtocol.OpenAIChat, binding.Protocol); + var updated = await _context.LLMChannels.FindAsync(27); + Assert.Equal("https://new.endpoint/v1", updated.Gateway); + Assert.Equal(LLMProvider.OpenAI, updated.Provider); + } + + [Fact] + public async Task SetDefaultBinding_SecondDefaultDemotedWithWarning() { + // Arrange: 数据异常——渠道已存在两个 IsDefault binding + var binding1 = await SeedChannelWithDefaultBinding(28, "OpenAI", LLMProvider.OpenAI, gateway: "http://a"); + var binding2 = new LLMApiBinding { LLMChannelId = 28, Endpoint = "http://b", Protocol = LlmProtocol.OpenAIResponses, AuthProfile = LlmAuthProfile.Bearer, IsDefault = true }; + await _context.LLMApiBindings.AddAsync(binding2); + await _context.SaveChangesAsync(); + + // Act + var ok = await _helper.SetDefaultBinding(28, "http://c", LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer); + + // Assert:至多一个 IsDefault(保留 Id 最小者),并记录告警 + Assert.True(ok); + var bindings = await _context.LLMApiBindings.Where(b => b.LLMChannelId == 28).ToListAsync(); + Assert.Equal(1, bindings.Count(b => b.IsDefault)); + Assert.True(bindings.Single(b => b.IsDefault).Id == Math.Min(binding1.Id, binding2.Id)); + _loggerMock.Verify(l => l.Log(LogLevel.Warning, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>()), Times.AtLeastOnce); + } + + [Fact] + public async Task SetModelPreferred_SecondPreferredDemotedWithWarning() { + // Arrange: 同一模型在两条 binding 上各有行,b1 行已 preferred + var binding1 = await SeedChannelWithDefaultBinding(29, "OpenAI", LLMProvider.OpenAI); + var binding2 = new LLMApiBinding { LLMChannelId = 29, Endpoint = "http://gw2", Protocol = LlmProtocol.OpenAIResponses, AuthProfile = LlmAuthProfile.Bearer, IsDefault = false }; + await _context.LLMApiBindings.AddAsync(binding2); + await _context.SaveChangesAsync(); + var row1 = new ChannelWithModel { LLMChannelId = 29, ModelName = "m", IsDeleted = false, ApiBindingId = binding1.Id, IsPreferred = true }; + var row2 = new ChannelWithModel { LLMChannelId = 29, ModelName = "m", IsDeleted = false, ApiBindingId = binding2.Id, IsPreferred = false }; + await _context.ChannelsWithModel.AddRangeAsync(row1, row2); + await _context.SaveChangesAsync(); + + // Act: 把 preferred 切到 binding2 的行(模型名大小写不同,验证 OIC 匹配) + var ok = await _helper.SetModelPreferred(29, "M", binding2.Id); + + // Assert:同 channel/model 至多一个 IsPreferred;目标行生效,旧行降级 + 告警 + Assert.True(ok); + var rows = await _context.ChannelsWithModel.Where(m => m.LLMChannelId == 29).ToListAsync(); + Assert.Equal(1, rows.Count(r => r.IsPreferred)); + Assert.True(rows.Single(r => r.IsPreferred).ApiBindingId == binding2.Id); + Assert.False(rows.Single(r => r.ApiBindingId == binding1.Id).IsPreferred); + _loggerMock.Verify(l => l.Log(LogLevel.Warning, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>()), Times.AtLeastOnce); + } + + [Fact] + public async Task SetModelPreferred_UnknownRowRejected() { + // Arrange + var binding1 = await SeedChannelWithDefaultBinding(31, "OpenAI", LLMProvider.OpenAI); + + // Act: 不存在的 (model, binding) 行 → 拒绝 + var ok = await _helper.SetModelPreferred(31, "nope", binding1.Id); + + // Assert + Assert.False(ok); + } + + [Fact] + public async Task UpdateChannel_GatewayAndProviderChange_SyncsDefaultBinding() { + // Arrange + var binding = await SeedChannelWithDefaultBinding(30, "OpenAI", LLMProvider.OpenAI, gateway: "http://old"); + + // Act 1: 编辑渠道地址 → 默认 binding Endpoint 同步 + var ok1 = await _helper.UpdateChannel(30, gateway: "http://new"); + Assert.True(ok1); + var bindingAfter = await _context.LLMApiBindings.FindAsync(binding.Id); + Assert.Equal("http://new", bindingAfter.Endpoint); + + // Act 2: 编辑渠道类型 → 默认 binding Protocol/Auth 同步 + var ok2 = await _helper.UpdateChannel(30, provider: LLMProvider.ResponsesAPI); + Assert.True(ok2); + bindingAfter = await _context.LLMApiBindings.FindAsync(binding.Id); + Assert.Equal(LlmProtocol.OpenAIResponses, bindingAfter.Protocol); + Assert.Equal(LlmAuthProfile.Bearer, bindingAfter.AuthProfile); + Assert.Equal("http://new", bindingAfter.Endpoint); + } } } diff --git a/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs b/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs index 2a5a82ce..7bfb2fc9 100644 --- a/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs +++ b/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs @@ -111,6 +111,8 @@ public EditLLMConfTest() { .ReturnsAsync((int id) => new LLMChannel { Id = id, Name = "Test Channel", Provider = LLMProvider.OpenAI }); helperMock.Setup(h => h.RefreshAllChannel()) .ReturnsAsync(2); + helperMock.Setup(h => h.GetModelsByChannelId(It.IsAny())) + .ReturnsAsync(new List()); _service = new EditLLMConfService( helperMock.Object, @@ -332,6 +334,9 @@ await _context.ChannelsWithModel.AddAsync(new ChannelWithModel { }); await _context.SaveChangesAsync(); + helperMock.Setup(h => h.GetModelsByChannelId(1)) + .ReturnsAsync(new List { "model1" }); + var stateKey = $"llmconf:{chatId}:state"; // Setup state transitions @@ -354,6 +359,44 @@ await _context.ChannelsWithModel.AddAsync(new ChannelWithModel { Assert.Contains("- model1", result2.Item2); } + [Fact] + public async Task ExecuteAsync_ViewModels_MultiBindingDisplay() { + // Arrange: 同模型多 binding 的管理展示 `model [channel/binding/protocol]`(格式由 GetModelsByChannelId 提供) + long chatId = 456; + var channel = new LLMChannel { + Id = 2, + Name = "Test Channel", + Gateway = "http://test.com", + ApiKey = "test-key", + Provider = LLMProvider.OpenAI + }; + await _context.LLMChannels.AddAsync(channel); + await _context.SaveChangesAsync(); + + helperMock.Setup(h => h.GetModelsByChannelId(2)) + .ReturnsAsync(new List { + "gpt-4o [Test Channel/1/OpenAIChat]", + "gpt-4o [Test Channel/2/OpenAIResponses]" + }); + + var stateKey = $"llmconf:{chatId}:state"; + _dbMock.SetupSequence(d => d.StringGetAsync(stateKey, It.IsAny())) + .ReturnsAsync(RedisValue.Null) + .ReturnsAsync("viewing_model_select_channel"); + + helperMock.Setup(h => h.GetAllChannels()) + .ReturnsAsync(new List { channel }); + + // Act & Assert + var result1 = await _service.ExecuteAsync("查看模型", chatId); + Assert.True(result1.Item1); + var result2 = await _service.ExecuteAsync("2", chatId); + Assert.True(result2.Item1); + Assert.Contains("渠道 Test Channel 下的模型列表:", result2.Item2); + Assert.Contains("- gpt-4o [Test Channel/1/OpenAIChat]", result2.Item2); + Assert.Contains("- gpt-4o [Test Channel/2/OpenAIResponses]", result2.Item2); + } + [Fact] public async Task ExecuteAsync_UpdateParallelAndPriority() { // Arrange diff --git a/TelegramSearchBot.Test/Service/AI/LLM/LLMTaskQueueServiceTests.cs b/TelegramSearchBot.Test/Service/AI/LLM/LLMTaskQueueServiceTests.cs index be95cac2..7d4a27c7 100644 --- a/TelegramSearchBot.Test/Service/AI/LLM/LLMTaskQueueServiceTests.cs +++ b/TelegramSearchBot.Test/Service/AI/LLM/LLMTaskQueueServiceTests.cs @@ -267,6 +267,251 @@ private static DataDbContext CreateDbContext() { return new DataDbContext(options); } + // ==================================================================== + // Phase 2:Agent 路径 binding 复制与序列化兼容 + // ==================================================================== + + [Fact] + public async Task EnqueueMessageTaskAsync_WithDefaultBinding_CopiesBindingIntoPayload() { + var originalFlag = Env.EnableLLMAgentProcess; + Env.EnableLLMAgentProcess = true; + + try { + await using var dbContext = CreateDbContext(); + var binding = SeedChannelWithBinding(dbContext, 401, "gpt-binding"); + dbContext.GroupSettings.Add(new GroupSettings { + GroupId = -4001, + LLMModelName = "gpt-binding" + }); + await dbContext.SaveChangesAsync(); + + var (service, getPayload) = CreateQueueService(dbContext); + var handle = await service.EnqueueMessageTaskAsync( + -4001, 456, 789, DateTime.UtcNow, "input", "bot", 1001); + + Assert.NotNull(handle); + var task = JsonConvert.DeserializeObject(getPayload()); + Assert.NotNull(task); + Assert.Equal(binding.Id, task!.Channel.BindingId); + Assert.Equal(binding.Endpoint, task.Channel.BindingEndpoint); + Assert.Equal(LlmProtocol.OpenAIChat, task.Channel.BindingProtocol); + Assert.Equal(LlmAuthProfile.Bearer, task.Channel.BindingAuthProfile); + Assert.Equal("https://legacy.example", task.Channel.Gateway); // legacy 字段保留 + Assert.Equal(LLMProvider.OpenAI, task.Channel.Provider); + } finally { + Env.EnableLLMAgentProcess = originalFlag; + } + } + + [Fact] + public async Task EnqueueMessageTaskAsync_IsPreferredBinding_WinsOverChannelDefault() { + var originalFlag = Env.EnableLLMAgentProcess; + Env.EnableLLMAgentProcess = true; + + try { + await using var dbContext = CreateDbContext(); + SeedChannelWithPreferredBinding(dbContext, 402, "gpt-preferred", out var preferredBinding); + dbContext.GroupSettings.Add(new GroupSettings { + GroupId = -4002, + LLMModelName = "gpt-preferred" + }); + await dbContext.SaveChangesAsync(); + + var (service, getPayload) = CreateQueueService(dbContext); + var handle = await service.EnqueueMessageTaskAsync( + -4002, 456, 789, DateTime.UtcNow, "input", "bot", 1001); + + Assert.NotNull(handle); + var task = JsonConvert.DeserializeObject(getPayload()); + Assert.NotNull(task); + Assert.Equal(preferredBinding.Id, task!.Channel.BindingId); + Assert.Equal(LlmProtocol.OpenAIResponses, task.Channel.BindingProtocol); + Assert.Equal(preferredBinding.Endpoint, task.Channel.BindingEndpoint); + } finally { + Env.EnableLLMAgentProcess = originalFlag; + } + } + + [Fact] + public async Task EnqueueMessageTaskAsync_LegacyNoBinding_NullBindingFields() { + var originalFlag = Env.EnableLLMAgentProcess; + Env.EnableLLMAgentProcess = true; + + try { + await using var dbContext = CreateDbContext(); + SeedChannel(dbContext, 403, "gpt-legacy"); + dbContext.GroupSettings.Add(new GroupSettings { + GroupId = -4003, + LLMModelName = "gpt-legacy" + }); + await dbContext.SaveChangesAsync(); + + var (service, getPayload) = CreateQueueService(dbContext); + var handle = await service.EnqueueMessageTaskAsync( + -4003, 456, 789, DateTime.UtcNow, "input", "bot", 1001); + + Assert.NotNull(handle); + var task = JsonConvert.DeserializeObject(getPayload()); + Assert.NotNull(task); + Assert.Null(task!.Channel.BindingId); + Assert.Null(task.Channel.BindingProtocol); + Assert.Null(task.Channel.BindingAuthProfile); + Assert.Equal(string.Empty, task.Channel.BindingEndpoint); + // legacy 路径:Agent 端按 Provider/Gateway 解析(LlmServiceProxy.ToBinding 返回 null) + Assert.Equal(LLMProvider.OpenAI, task.Channel.Provider); + Assert.Equal("https://example.invalid", task.Channel.Gateway); + } finally { + Env.EnableLLMAgentProcess = originalFlag; + } + } + + [Fact] + public void AgentChannelConfig_LegacyJson_DeserializesWithNullBindingFields() { + // 旧 LLMAgent 二进制序列化的 config 没有 binding 字段 → 新二进制解析后走 legacy 路径 + var legacyJson = "{\"ChannelId\":321,\"Name\":\"c\",\"Gateway\":\"https://x\",\"ApiKey\":\"k\",\"Provider\":1,\"Parallel\":1,\"Priority\":10,\"ModelName\":\"m\",\"Capabilities\":[]}"; + + var config = JsonConvert.DeserializeObject(legacyJson); + + Assert.NotNull(config); + Assert.Null(config!.BindingId); + Assert.Null(config.BindingProtocol); + Assert.Null(config.BindingAuthProfile); + Assert.Equal(string.Empty, config.BindingEndpoint); + Assert.Equal(LLMProvider.OpenAI, config.Provider); + } + + [Fact] + public void AgentChannelConfig_NewJson_RoundTripsBindingFields() { + // 新二进制序列化含 binding 字段;旧 LLMAgent 二进制反序列化时忽略未知字段(Newtonsoft 行为),不崩溃 + var config = new AgentChannelConfig { + ChannelId = 321, + Name = "c", + Gateway = "https://legacy", + ApiKey = "k", + Provider = LLMProvider.OpenAI, + Parallel = 1, + Priority = 10, + ModelName = "m", + BindingId = 7, + BindingEndpoint = "https://opencode.ai/zen/v1", + BindingProtocol = LlmProtocol.OpenAIChat, + BindingAuthProfile = LlmAuthProfile.Bearer + }; + + var json = JsonConvert.SerializeObject(config); + var back = JsonConvert.DeserializeObject(json); + + Assert.NotNull(back); + Assert.Equal(7, back!.BindingId); + Assert.Equal("https://opencode.ai/zen/v1", back.BindingEndpoint); + Assert.Equal(LlmProtocol.OpenAIChat, back.BindingProtocol); + Assert.Equal(LlmAuthProfile.Bearer, back.BindingAuthProfile); + } + + private static (LLMTaskQueueService Service, Func Payload) CreateQueueService(DataDbContext dbContext) { + var redisMock = new Mock(); + var dbMock = new Mock(); + redisMock.Setup(r => r.GetDatabase(It.IsAny(), It.IsAny())).Returns(dbMock.Object); + + dbMock.Setup(d => d.HashGetAllAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync([ + new HashEntry("chatId", -1), + new HashEntry("processId", 999), + new HashEntry("port", 0), + new HashEntry("status", "idle"), + new HashEntry("lastHeartbeatUtc", DateTime.UtcNow.ToString("O")), + new HashEntry("lastActiveAtUtc", DateTime.UtcNow.ToString("O")) + ]); + + string pushedPayload = string.Empty; + dbMock.Setup(d => d.ListLeftPushAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((_, value, _, _) => pushedPayload = value.ToString()) + .ReturnsAsync(1); + dbMock.Setup(d => d.HashSetAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var registry = new AgentRegistryService( + redisMock.Object, + Mock.Of(), + Mock.Of>()); + var polling = new ChunkPollingService(redisMock.Object); + var service = new LLMTaskQueueService(dbContext, redisMock.Object, polling, registry); + return (service, () => pushedPayload); + } + + private static LLMApiBinding SeedChannelWithBinding(DataDbContext dbContext, int channelId, string modelName) { + return SeedChannelWithBindings(dbContext, channelId, modelName, preferredProtocol: null, out _); + } + + private static LLMApiBinding SeedChannelWithPreferredBinding(DataDbContext dbContext, int channelId, string modelName, out LLMApiBinding preferredBinding) { + return SeedChannelWithBindings(dbContext, channelId, modelName, LlmProtocol.OpenAIResponses, out preferredBinding); + } + + private static LLMApiBinding SeedChannelWithBindings(DataDbContext dbContext, int channelId, string modelName, LlmProtocol? preferredProtocol, out LLMApiBinding preferredBinding) { + var channel = new LLMChannel { + Id = channelId, + Name = "test-channel", + Gateway = "https://legacy.example", + ApiKey = "key", + Provider = LLMProvider.OpenAI, + Parallel = 1, + Priority = 10 + }; + var defaultBinding = new LLMApiBinding { + Id = channelId * 10 + 1, + LLMChannelId = channelId, + Endpoint = "https://opencode.ai/zen/v1", + Protocol = LlmProtocol.OpenAIChat, + AuthProfile = LlmAuthProfile.Bearer, + IsDefault = true + }; + var preferred = new LLMApiBinding { + Id = channelId * 10 + 2, + LLMChannelId = channelId, + Endpoint = "https://opencode.ai/zen/go/v1", + Protocol = LlmProtocol.OpenAIResponses, + AuthProfile = LlmAuthProfile.Bearer, + IsDefault = false + }; + preferredBinding = preferred; + + var defaultRow = new ChannelWithModel { + Id = channelId * 100 + 1, + LLMChannelId = channelId, + LLMChannel = channel, + ModelName = modelName, + IsDeleted = false, + ApiBindingId = defaultBinding.Id, + ApiBinding = defaultBinding, + Capabilities = new List() + }; + var preferredRow = new ChannelWithModel { + Id = channelId * 100 + 2, + LLMChannelId = channelId, + LLMChannel = channel, + ModelName = modelName, + IsDeleted = false, + ApiBindingId = preferred.Id, + ApiBinding = preferred, + IsPreferred = preferredProtocol.HasValue, + Capabilities = new List() + }; + + channel.Bindings.Add(defaultBinding); + channel.Bindings.Add(preferred); + dbContext.LLMChannels.Add(channel); + dbContext.LLMApiBindings.AddRange(defaultBinding, preferred); + dbContext.ChannelsWithModel.Add(defaultRow); + dbContext.ChannelsWithModel.Add(preferredRow); + dbContext.SaveChanges(); + return defaultBinding; + } + private static void SeedChannel(DataDbContext dbContext, int channelId, string modelName) { var channel = new LLMChannel { Id = channelId, diff --git a/TelegramSearchBot.Test/Service/Database/LlmApiBindingTests.cs b/TelegramSearchBot.Test/Service/Database/LlmApiBindingTests.cs new file mode 100644 index 00000000..7c26898e --- /dev/null +++ b/TelegramSearchBot.Test/Service/Database/LlmApiBindingTests.cs @@ -0,0 +1,249 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using TelegramSearchBot.Model; +using TelegramSearchBot.Model.AI; +using TelegramSearchBot.Model.Data; +using Xunit; + +namespace TelegramSearchBot.Test.Service.Database { + /// + /// LLMApiBinding 相关测试:真实 SQLite 升级路径回填 + 基本 CRUD(InMemory)。 + /// 升级路径必须用真实 SQLite(EF InMemory 不执行迁移)。 + /// + public class LlmApiBindingTests { + private static readonly string LegacyMigration = "20260313124507_AddChannelWithModelIsDeleted"; + + private static SqliteConnection OpenSqliteConnection() { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + private static DbContextOptions CreateSqliteOptions(SqliteConnection connection) { + return new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + } + + private static DbContextOptions CreateInMemoryOptions() { + return new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + } + + /// + /// 旧库(legacy 迁移)→ 种子每个 provider 的 channel/model → 迁移到最新 → + /// 断言 default binding 回填、协议/认证映射、模型 FK 回填、legacy 字段保留。 + /// + [Fact] + public async Task UpgradeFromLegacyDatabase_BackfillsBindingsAndModels() { + using var connection = OpenSqliteConnection(); + var options = CreateSqliteOptions(connection); + + // 1. 旧库 + 种子 legacy 数据(raw SQL:模拟旧二进制写 legacy schema, + // 当前模型的 ApiBindingId 列在旧 schema 中不存在) + using (var ctx = new DataDbContext(options)) { + ctx.Database.Migrate(LegacyMigration); + + var openaiId = await InsertLegacyChannel(ctx, "openai", "https://api.openai.com/v1", "sk-openai", LLMProvider.OpenAI, 10); + var ollamaId = await InsertLegacyChannel(ctx, "ollama", "http://localhost:11434", null, LLMProvider.Ollama, 9); + var geminiId = await InsertLegacyChannel(ctx, "gemini", "https://generativelanguage.googleapis.com", "AI-gemini", LLMProvider.Gemini, 8); + var minimaxId = await InsertLegacyChannel(ctx, "minimax", "https://api.minimax.chat", "mm-key", LLMProvider.MiniMax, 7); + var lmstudioId = await InsertLegacyChannel(ctx, "lmstudio", "http://localhost:1234/v1", null, LLMProvider.LMStudio, 6); + var anthropicId = await InsertLegacyChannel(ctx, "anthropic", "https://api.anthropic.com", "sk-ant", LLMProvider.Anthropic, 5); + var responsesId = await InsertLegacyChannel(ctx, "responses", "https://api.openai.com/v1", "sk-resp", LLMProvider.ResponsesAPI, 4); + // 无模型的 channel:仍应获得一个 default binding + await InsertLegacyChannel(ctx, "empty", "http://localhost:9999/v1", "sk-empty", LLMProvider.OpenAI, 3); + + // 大小写重复对:升级必须成功且两行都在 + await InsertLegacyModel(ctx, openaiId, "gpt-4o", false); + await InsertLegacyModel(ctx, openaiId, "GPT-4O", false); + await InsertLegacyModel(ctx, openaiId, "gpt-4o-mini", true); + await InsertLegacyModel(ctx, ollamaId, "llama3", false); + await InsertLegacyModel(ctx, geminiId, "gemini-2.5-pro", false); + await InsertLegacyModel(ctx, minimaxId, "MiniMax-Text-01", false); + await InsertLegacyModel(ctx, lmstudioId, "qwen2.5-7b", false); + await InsertLegacyModel(ctx, anthropicId, "claude-sonnet-4-5", false); + await InsertLegacyModel(ctx, responsesId, "gpt-4.1", false); + } + + // 2. 升级到最新(含 AddLlmApiBinding 回填) + using (var ctx = new DataDbContext(options)) { + ctx.Database.Migrate(); + } + + // 3. 断言 + using (var ctx = new DataDbContext(options)) { + var channels = await ctx.LLMChannels + .Include(c => c.Bindings) + .Include(c => c.Models) + .OrderBy(c => c.Id) + .ToListAsync(); + + Assert.Equal(8, channels.Count); + + foreach (var channel in channels) { + // 每 channel 恰好一个 default binding + var defaults = channel.Bindings.Where(b => b.IsDefault).ToList(); + Assert.Single(defaults); + var binding = defaults[0]; + + // Endpoint 镜像 legacy Gateway + Assert.Equal(channel.Gateway, binding.Endpoint); + + // 协议映射 + var expectedProtocol = channel.Provider switch { + LLMProvider.OpenAI or LLMProvider.MiniMax or LLMProvider.LMStudio => LlmProtocol.OpenAIChat, + LLMProvider.ResponsesAPI => LlmProtocol.OpenAIResponses, + LLMProvider.Anthropic => LlmProtocol.AnthropicMessages, + LLMProvider.Ollama => LlmProtocol.Ollama, + LLMProvider.Gemini => LlmProtocol.Gemini, + _ => LlmProtocol.OpenAIChat + }; + Assert.Equal(expectedProtocol, binding.Protocol); + + // 认证映射 + var expectedAuth = channel.Provider switch { + LLMProvider.Anthropic => LlmAuthProfile.AnthropicApiKey, + LLMProvider.Ollama => LlmAuthProfile.None, + _ => LlmAuthProfile.Bearer + }; + Assert.Equal(expectedAuth, binding.AuthProfile); + } + + // legacy 字段原样保留 + var openaiChannel = channels.Single(c => c.Name == "openai"); + Assert.Equal("https://api.openai.com/v1", openaiChannel.Gateway); + Assert.Equal("sk-openai", openaiChannel.ApiKey); + Assert.Equal(LLMProvider.OpenAI, openaiChannel.Provider); + Assert.Equal(10, openaiChannel.Priority); + + var ollamaChannel = channels.Single(c => c.Name == "ollama"); + Assert.Equal(LLMProvider.Ollama, ollamaChannel.Provider); + Assert.Equal(LLMProvider.Anthropic, channels.Single(c => c.Name == "anthropic").Provider); + + // 每个非孤儿模型行都回填了其 channel 的 default binding + foreach (var channel in channels) { + var defaultBinding = channel.Bindings.Single(b => b.IsDefault); + foreach (var model in channel.Models) { + Assert.NotNull(model.ApiBindingId); + Assert.Equal(defaultBinding.Id, model.ApiBindingId); + // 回填行默认值 + Assert.Equal(AuthorizationSource.Manual, model.AuthorizationSource); + Assert.False(model.IsPreferred); + } + } + + // 大小写重复对升级后都在,且已回填 + var openaiModels = openaiChannel.Models.Select(m => m.ModelName).OrderBy(n => n).ToList(); + Assert.Contains("gpt-4o", openaiModels); + Assert.Contains("GPT-4O", openaiModels); + Assert.Equal(3, openaiModels.Count); + + // IsDeleted 模型也回填 + var deleted = openaiChannel.Models.Single(m => m.IsDeleted); + Assert.Equal(DefaultBindingId(openaiChannel), deleted.ApiBindingId); + + // 旧二进制模拟:ApiBindingId=NULL 的行可写可查,无 FK 违规 + var legacyWrite = new ChannelWithModel { + ModelName = "legacy-write-model", + LLMChannelId = openaiChannel.Id, + ApiBindingId = null + }; + ctx.ChannelsWithModel.Add(legacyWrite); + await ctx.SaveChangesAsync(); + var loaded = await ctx.ChannelsWithModel + .AsNoTracking() + .SingleAsync(m => m.Id == legacyWrite.Id); + Assert.Null(loaded.ApiBindingId); + Assert.Equal(AuthorizationSource.Manual, loaded.AuthorizationSource); + Assert.False(loaded.IsPreferred); + } + } + + private static int DefaultBindingId(LLMChannel channel) { + return channel.Bindings.Single(b => b.IsDefault).Id; + } + + /// + /// 全新空库直接迁移到最新(含回填 SQL 对空表的幂等性)。 + /// + [Fact] + public void FreshDatabase_MigrateToLatest_SucceedsWithNoChannels() { + using var connection = OpenSqliteConnection(); + var options = CreateSqliteOptions(connection); + using var ctx = new DataDbContext(options); + ctx.Database.Migrate(); + Assert.Empty(ctx.LLMApiBindings); + } + + private static async Task InsertLegacyChannel(DataDbContext ctx, string name, string gateway, string apiKey, + LLMProvider provider, int priority) { + await ctx.Database.ExecuteSqlRawAsync( + "INSERT INTO LLMChannels (Name, Gateway, ApiKey, Provider, Parallel, Priority) VALUES ({0}, {1}, {2}, {3}, 1, {4})", + name, gateway, apiKey, (int)provider, priority); + return Convert.ToInt32(await ctx.Database.SqlQueryRaw("SELECT last_insert_rowid() AS Value").SingleAsync()); + } + + private static async Task InsertLegacyModel(DataDbContext ctx, int channelId, string modelName, bool isDeleted) { + await ctx.Database.ExecuteSqlRawAsync( + "INSERT INTO ChannelsWithModel (ModelName, LLMChannelId, IsDeleted) VALUES ({0}, {1}, {2})", + modelName, channelId, isDeleted); + } + + [Fact] + public async Task LlmApiBindings_BasicCrud() { + var options = CreateInMemoryOptions(); + using (var ctx = new DataDbContext(options)) { + var channel = new LLMChannel { + Name = "crud", + Gateway = "https://example.com/v1", + ApiKey = "k", + Provider = LLMProvider.OpenAI, + Parallel = 1, + Priority = 1 + }; + var binding = new LLMApiBinding { + LLMChannel = channel, + Endpoint = "https://example.com/v1", + Protocol = LlmProtocol.OpenAIChat, + AuthProfile = LlmAuthProfile.Bearer, + IsDefault = true + }; + ctx.LLMApiBindings.Add(binding); + await ctx.SaveChangesAsync(); + Assert.True(binding.Id > 0); + } + + using (var ctx = new DataDbContext(options)) { + var binding = await ctx.LLMApiBindings + .Include(b => b.LLMChannel) + .SingleAsync(); + Assert.Equal("crud", binding.LLMChannel.Name); + Assert.Equal(LlmProtocol.OpenAIChat, binding.Protocol); + Assert.Equal(LlmAuthProfile.Bearer, binding.AuthProfile); + Assert.True(binding.IsDefault); + Assert.Equal("https://example.com/v1", binding.Endpoint); + + // 更新 + binding.AuthProfile = LlmAuthProfile.None; + binding.IsDefault = false; + await ctx.SaveChangesAsync(); + } + + using (var ctx = new DataDbContext(options)) { + var binding = await ctx.LLMApiBindings.SingleAsync(); + Assert.Equal(LlmAuthProfile.None, binding.AuthProfile); + Assert.False(binding.IsDefault); + + // 删除 + ctx.LLMApiBindings.Remove(binding); + await ctx.SaveChangesAsync(); + Assert.Empty(await ctx.LLMApiBindings.ToListAsync()); + } + } + } +} diff --git a/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs b/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs index d41de9bd..bd35d4cd 100644 --- a/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs +++ b/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs @@ -16,5 +16,17 @@ public interface IEditLLMConfHelper { Task AddModelWithChannel(int channelId, List modelNames); Task UpdateChannel(int channelId, string? name = null, string? gateway = null, string? apiKey = null, LLMProvider? provider = null, int? parallel = null, int? priority = null); Task> GetModelsByChannelId(long channelId); + + /// + /// 设置(或创建)渠道的默认 binding 并保证每渠道至多一个 IsDefault; + /// 同时镜像 LLMChannel.Gateway/Provider,使旧二进制继续走默认协议(blueprint §七)。 + /// + Task SetDefaultBinding(int channelId, string endpoint, LlmProtocol protocol, LlmAuthProfile authProfile); + + /// + /// 设置模型级协议覆盖:渠道内同一模型(忽略大小写)至多一个 IsPreferred 行, + /// 已有 preferred 时降级并告警(遵循 phase-2 resolver 的告警+稳定解析约定)。 + /// + Task SetModelPreferred(int channelId, string modelName, int bindingId); } } diff --git a/TelegramSearchBot/Service/AI/LLM/LLMTaskQueueService.cs b/TelegramSearchBot/Service/AI/LLM/LLMTaskQueueService.cs index f2c00a0d..cd4051a4 100644 --- a/TelegramSearchBot/Service/AI/LLM/LLMTaskQueueService.cs +++ b/TelegramSearchBot/Service/AI/LLM/LLMTaskQueueService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; using StackExchange.Redis; using TelegramSearchBot.Attributes; @@ -23,18 +24,21 @@ public class LLMTaskQueueService : IService { private readonly ChunkPollingService _chunkPollingService; private readonly AgentRegistryService _agentRegistryService; private readonly LlmVisibilityService _llmVisibilityService; + private readonly ILogger _logger; public LLMTaskQueueService( DataDbContext dbContext, IConnectionMultiplexer redis, ChunkPollingService chunkPollingService, AgentRegistryService agentRegistryService, - LlmVisibilityService llmVisibilityService = null) { + LlmVisibilityService llmVisibilityService = null, + ILogger logger = null) { _dbContext = dbContext; _redis = redis; _chunkPollingService = chunkPollingService; _agentRegistryService = agentRegistryService; _llmVisibilityService = llmVisibilityService; + _logger = logger; } public string ServiceName => nameof(LLMTaskQueueService); @@ -216,33 +220,48 @@ private async Task BuildMessageTaskAsync( } private async Task LoadChannelAsync(string modelName, int? channelId, CancellationToken cancellationToken) { - var query = _dbContext.ChannelsWithModel.AsNoTracking() - .Include(x => x.LLMChannel) + var rows = await _dbContext.ChannelsWithModel.AsNoTracking() + .Include(x => x.ApiBinding) + .Include(x => x.LLMChannel).ThenInclude(c => c.Bindings) .Include(x => x.Capabilities) - .Where(x => !x.IsDeleted && x.ModelName == modelName); + .Where(x => !x.IsDeleted && x.ModelName == modelName) + .ToListAsync(cancellationToken); if (channelId.HasValue) { - query = query.Where(x => x.LLMChannelId == channelId.Value); + rows = rows.Where(x => x.LLMChannelId == channelId.Value).ToList(); } - var channelWithModel = await query - .OrderByDescending(x => x.LLMChannel.Priority) - .FirstOrDefaultAsync(cancellationToken); + if (rows.Count == 0) { + throw new InvalidOperationException($"找不到模型 {modelName} 可用的渠道配置。"); + } + + // 确定性路由:渠道 Priority DESC,渠道内按 IsPreferred/IsDefault/binding.Id 解析(与 General 路径同一 resolver) + var route = LlmRouteResolver.ResolveFirst( + rows + .OrderByDescending(x => x.LLMChannel.Priority) + .GroupBy(x => x.LLMChannelId) + .Select(g => (g.First().LLMChannel, g.ToList())), + modelName, + _logger); - if (channelWithModel?.LLMChannel == null) { + if (route == null) { throw new InvalidOperationException($"找不到模型 {modelName} 可用的渠道配置。"); } return new AgentChannelConfig { - ChannelId = channelWithModel.LLMChannel.Id, - Name = channelWithModel.LLMChannel.Name, - Gateway = channelWithModel.LLMChannel.Gateway, - ApiKey = channelWithModel.LLMChannel.ApiKey, - Provider = channelWithModel.LLMChannel.Provider, - Parallel = channelWithModel.LLMChannel.Parallel, - Priority = channelWithModel.LLMChannel.Priority, - ModelName = channelWithModel.ModelName, - Capabilities = channelWithModel.Capabilities + ChannelId = route.Channel.Id, + Name = route.Channel.Name, + Gateway = route.Channel.Gateway, + ApiKey = route.Channel.ApiKey, + Provider = route.Channel.Provider, + Parallel = route.Channel.Parallel, + Priority = route.Channel.Priority, + ModelName = route.Model.ModelName, + BindingId = route.Binding?.Id, + BindingEndpoint = route.Binding?.Endpoint ?? string.Empty, + BindingProtocol = route.Binding?.Protocol, + BindingAuthProfile = route.Binding?.AuthProfile, + Capabilities = route.Model.Capabilities .Select(x => new AgentModelCapability { Name = x.CapabilityName, Value = x.CapabilityValue, diff --git a/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs b/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs index dadf13af..190d9066 100644 --- a/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs +++ b/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs @@ -57,6 +57,9 @@ public async Task AddChannel(string Name, string Gateway, string ApiKey, LL await DataContext.LLMChannels.AddAsync(channel); await DataContext.SaveChangesAsync(); + // 管理修复:每渠道恰好一个默认 binding(blueprint §六.8/§八-阶段3) + var defaultBinding = await EnsureDefaultBinding(channel); + _logger.LogInformation("成功添加新通道: {ChannelName} ({Provider})", Name, Provider); IEnumerable models; @@ -66,15 +69,21 @@ public async Task AddChannel(string Name, string Gateway, string ApiKey, LL return -1; } - models = await service.GetAllModels(channel); - var list = new List(); - foreach (var e in models) { - list.Add(new ChannelWithModel() { LLMChannelId = channel.Id, ModelName = e, IsDeleted = false }); - } - await DataContext.ChannelsWithModel.AddRangeAsync(list); - await DataContext.SaveChangesAsync(); + // Catalog ≠ Entitlement(blueprint §四.5):OpenCode 目录不自动创建模型行, + // 管理员通过“添加模型”手工维护授权集合。 + if (IsOpenCodeBinding(defaultBinding)) { + _logger.LogInformation("通道 {ChannelName} 默认 binding 为 OpenCode 目录(opencode.ai/zen/*),不自动创建模型行,请手工添加模型", Name); + } else { + models = await service.GetAllModels(channel); + var list = new List(); + foreach (var e in models) { + list.Add(new ChannelWithModel() { LLMChannelId = channel.Id, ModelName = e, IsDeleted = false, AuthorizationSource = AuthorizationSource.Discovered, ApiBindingId = defaultBinding?.Id }); + } + await DataContext.ChannelsWithModel.AddRangeAsync(list); + await DataContext.SaveChangesAsync(); - _logger.LogInformation("为新通道 {ChannelName} 添加了 {Count} 个模型", Name, list.Count); + _logger.LogInformation("为新通道 {ChannelName} 添加了 {Count} 个模型", Name, list.Count); + } // 获取并存储模型能力信息 _logger.LogInformation("正在获取通道 {ChannelName} 的模型能力信息...", Name); @@ -95,12 +104,24 @@ public async Task AddChannel(string Name, string Gateway, string ApiKey, LL public async Task RefreshAllChannel() { var count = 0; - var channels = from s in DataContext.LLMChannels - select s; + var channels = await DataContext.LLMChannels + .Include(c => c.Bindings) + .ToListAsync(); _logger.LogInformation("开始刷新所有通道的模型和能力信息..."); foreach (var channel in channels) { + // 管理修复:每渠道恰好一个默认 binding(blueprint §六.8) + var defaultBinding = await EnsureDefaultBinding(channel); + + // Catalog ≠ Entitlement(blueprint §四.1/.5):OpenCode /models 不是授权快照, + // 刷新不得创建、不得软删任何模型行;能力 metadata 仍可安全 merge(不会创建/复活行)。 + if (IsOpenCodeBinding(defaultBinding)) { + _logger.LogInformation("通道 {ChannelName} 默认 binding 为 OpenCode 目录(opencode.ai/zen/*),跳过目录创建/软删", channel.Name); + await TryUpdateCapabilitiesAsync(channel.Id); + continue; + } + var service = _LLMFactory.GetLLMService(channel.Provider); if (service == null) { _logger.LogWarning("未找到通道 {ChannelName} ({Provider}) 的LLM服务", channel.Name, channel.Provider); @@ -111,46 +132,44 @@ public async Task RefreshAllChannel() { try { IEnumerable models = await service.GetAllModels(channel); - var modelSet = models.ToHashSet(); + var modelSet = models.ToHashSet(StringComparer.OrdinalIgnoreCase); // 获取该通道下所有已有记录(包含已软删除的) var existingRecords = await DataContext.ChannelsWithModel .Where(x => x.LLMChannelId == channel.Id) .ToListAsync(); - // 恢复之前被标记删除但现在重新出现的模型 - var toRestore = existingRecords - .Where(x => x.IsDeleted && modelSet.Contains(x.ModelName)) + // 本刷新只作用于同 binding(默认路由)的 Discovered 行; + // Manual 行永不被刷新软删/复活(blueprint §四.5)。 + var scopedRecords = existingRecords + .Where(x => x.AuthorizationSource == AuthorizationSource.Discovered + && x.ApiBindingId == defaultBinding?.Id) .ToList(); - foreach (var record in toRestore) { + + // 恢复之前被标记删除但现在重新出现的模型(仅 Discovered 行) + foreach (var record in scopedRecords.Where(x => x.IsDeleted && modelSet.Contains(x.ModelName))) { record.IsDeleted = false; count++; _logger.LogInformation("通道 {ChannelName} 恢复模型 {ModelName}", channel.Name, record.ModelName); } - // 标记已有记录中不再存在于 API 的模型为已删除 - var existingModelNames = existingRecords - .Where(x => !x.IsDeleted) - .Select(x => x.ModelName) - .ToHashSet(); - var toDelete = channel.Provider == LLMProvider.MiniMax - ? new List() - : existingRecords - .Where(x => !x.IsDeleted && !modelSet.Contains(x.ModelName)) - .ToList(); - foreach (var record in toDelete) { + // 标记已有 Discovered 记录中不再存在于 API 的模型为已删除; + // MiniMax 动态发现可能暂时不可用,不软删 MiniMax 模型(#387); + // Manual 行永不被刷新软删(blueprint §四.5)。 + foreach (var record in scopedRecords.Where(x => !x.IsDeleted && !modelSet.Contains(x.ModelName) && channel.Provider != LLMProvider.MiniMax)) { record.IsDeleted = true; _logger.LogInformation("通道 {ChannelName} 标记删除消失的模型 {ModelName}", channel.Name, record.ModelName); } - // 添加全新的模型(既未存在也未被软删除过) - var allExistingNames = existingRecords.Select(x => x.ModelName).ToHashSet(); + // 添加全新的模型(同 binding 内忽略大小写去重,blueprint §七.6) var toAdd = modelSet - .Where(m => !allExistingNames.Contains(m)) + .Where(m => !scopedRecords.Any(r => r.ModelName.Equals(m, StringComparison.OrdinalIgnoreCase))) .Select(m => new ChannelWithModel { LLMChannelId = channel.Id, ModelName = m, - IsDeleted = false + IsDeleted = false, + AuthorizationSource = AuthorizationSource.Discovered, + ApiBindingId = defaultBinding?.Id }) .ToList(); @@ -162,25 +181,30 @@ public async Task RefreshAllChannel() { // 保存变更 await DataContext.SaveChangesAsync(); - - // 刷新此通道的模型能力信息 - _logger.LogInformation("正在更新通道 {ChannelName} 的模型能力信息...", channel.Name); - bool capabilityUpdateSuccess = await _modelCapabilityService.UpdateChannelModelCapabilities(channel.Id); - - if (capabilityUpdateSuccess) { - _logger.LogInformation("成功更新通道 {ChannelName} 的模型能力信息", channel.Name); - } else { - _logger.LogWarning("更新通道 {ChannelName} 的模型能力信息失败", channel.Name); - } } catch (Exception ex) { + // 抓取失败:整 channel 跳过,不产生任何创建/软删(Manual 与 Discovered 均不受影响) _logger.LogError(ex, "刷新通道 {ChannelName} ({Provider}) 时出错", channel.Name, channel.Provider); } + + await TryUpdateCapabilitiesAsync(channel.Id); } _logger.LogInformation("完成刷新所有通道,共添加/恢复了 {Count} 个模型", count); return count; } + private async Task TryUpdateCapabilitiesAsync(int channelId) { + // 刷新此通道的模型能力信息 + _logger.LogInformation("正在更新通道 {ChannelId} 的模型能力信息...", channelId); + bool capabilityUpdateSuccess = await _modelCapabilityService.UpdateChannelModelCapabilities(channelId); + + if (capabilityUpdateSuccess) { + _logger.LogInformation("成功更新通道 {ChannelId} 的模型能力信息", channelId); + } else { + _logger.LogWarning("更新通道 {ChannelId} 的模型能力信息失败", channelId); + } + } + /// /// 获取所有LLM通道列表 /// @@ -254,8 +278,10 @@ public async Task RemoveModelFromChannel(int channelId, string modelName) // Skip transaction for InMemory database if (DataContext.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory") { try { - var model = await DataContext.ChannelsWithModel - .FirstOrDefaultAsync(m => m.LLMChannelId == channelId && m.ModelName == modelName); + var rows = await DataContext.ChannelsWithModel + .Where(m => m.LLMChannelId == channelId) + .ToListAsync(); + var model = rows.FirstOrDefault(m => m.ModelName.Equals(modelName, StringComparison.OrdinalIgnoreCase)); if (model != null) { DataContext.ChannelsWithModel.Remove(model); @@ -268,8 +294,10 @@ public async Task RemoveModelFromChannel(int channelId, string modelName) } else { using var transaction = await DataContext.Database.BeginTransactionAsync(); try { - var model = await DataContext.ChannelsWithModel - .FirstOrDefaultAsync(m => m.LLMChannelId == channelId && m.ModelName == modelName); + var rows = await DataContext.ChannelsWithModel + .Where(m => m.LLMChannelId == channelId) + .ToListAsync(); + var model = rows.FirstOrDefault(m => m.ModelName.Equals(modelName, StringComparison.OrdinalIgnoreCase)); if (model != null) { DataContext.ChannelsWithModel.Remove(model); @@ -290,27 +318,34 @@ public async Task RemoveModelFromChannel(int channelId, string modelName) /// LLM通道ID /// 要关联的模型名称列表 /// 成功返回true,失败返回false - /// - /// 更新LLM通道信息 - /// public async Task AddModelWithChannel(int channelId, List modelNames) { if (modelNames == null || modelNames.Count == 0) { return false; } + // 管理员手工添加 = Manual 授权(blueprint §四.5);新行关联默认 binding + var defaultBinding = await DataContext.LLMApiBindings + .Where(b => b.LLMChannelId == channelId && b.IsDefault) + .OrderBy(b => b.Id) + .FirstOrDefaultAsync(); + // Skip transaction for InMemory database if (DataContext.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory") { try { + var existingRows = await DataContext.ChannelsWithModel + .Where(m => m.LLMChannelId == channelId) + .ToListAsync(); foreach (var modelName in modelNames) { - var existing = await DataContext.ChannelsWithModel - .FirstOrDefaultAsync(m => m.LLMChannelId == channelId && m.ModelName == modelName); + // 忽略大小写去重(blueprint §七.6),不新建重复行 + var existing = existingRows.FirstOrDefault(m => m.ModelName.Equals(modelName, StringComparison.OrdinalIgnoreCase)); if (existing != null) { existing.IsDeleted = false; } else { await DataContext.ChannelsWithModel.AddAsync(new ChannelWithModel { LLMChannelId = channelId, ModelName = modelName, - IsDeleted = false + IsDeleted = false, + ApiBindingId = defaultBinding?.Id }); } } @@ -322,16 +357,20 @@ await DataContext.ChannelsWithModel.AddAsync(new ChannelWithModel { } else { using var transaction = await DataContext.Database.BeginTransactionAsync(); try { + var existingRows = await DataContext.ChannelsWithModel + .Where(m => m.LLMChannelId == channelId) + .ToListAsync(); foreach (var modelName in modelNames) { - var existing = await DataContext.ChannelsWithModel - .FirstOrDefaultAsync(m => m.LLMChannelId == channelId && m.ModelName == modelName); + // 忽略大小写去重(blueprint §七.6),不新建重复行 + var existing = existingRows.FirstOrDefault(m => m.ModelName.Equals(modelName, StringComparison.OrdinalIgnoreCase)); if (existing != null) { existing.IsDeleted = false; } else { await DataContext.ChannelsWithModel.AddAsync(new ChannelWithModel { LLMChannelId = channelId, ModelName = modelName, - IsDeleted = false + IsDeleted = false, + ApiBindingId = defaultBinding?.Id }); } } @@ -360,16 +399,19 @@ public async Task UpdateChannel(int channelId, string? name = null, string return false; } + var gatewayChanged = !string.IsNullOrWhiteSpace(gateway) && gateway != channel.Gateway; + var providerChanged = provider.HasValue && provider.Value != channel.Provider; + if (!string.IsNullOrWhiteSpace(name)) { channel.Name = name; } - if (!string.IsNullOrWhiteSpace(gateway)) { + if (gatewayChanged) { channel.Gateway = gateway; } if (!string.IsNullOrWhiteSpace(apiKey)) { channel.ApiKey = apiKey; } - if (provider.HasValue) { + if (providerChanged) { channel.Provider = provider.Value; } if (parallel.HasValue) { @@ -379,6 +421,11 @@ public async Task UpdateChannel(int channelId, string? name = null, string channel.Priority = priority.Value; } + // 镜像规则(blueprint §七):channel Gateway/Provider 变更时同步默认 binding + if (gatewayChanged || providerChanged) { + await SyncDefaultBindingAsync(channel); + } + await DataContext.SaveChangesAsync(); return true; } catch { @@ -392,16 +439,19 @@ public async Task UpdateChannel(int channelId, string? name = null, string return false; } + var gatewayChanged = !string.IsNullOrWhiteSpace(gateway) && gateway != channel.Gateway; + var providerChanged = provider.HasValue && provider.Value != channel.Provider; + if (!string.IsNullOrWhiteSpace(name)) { channel.Name = name; } - if (!string.IsNullOrWhiteSpace(gateway)) { + if (gatewayChanged) { channel.Gateway = gateway; } if (!string.IsNullOrWhiteSpace(apiKey)) { channel.ApiKey = apiKey; } - if (provider.HasValue) { + if (providerChanged) { channel.Provider = provider.Value; } if (parallel.HasValue) { @@ -411,6 +461,11 @@ public async Task UpdateChannel(int channelId, string? name = null, string channel.Priority = priority.Value; } + // 镜像规则(blueprint §七):channel Gateway/Provider 变更时同步默认 binding + if (gatewayChanged || providerChanged) { + await SyncDefaultBindingAsync(channel); + } + await DataContext.SaveChangesAsync(); await transaction.CommitAsync(); return true; @@ -421,12 +476,204 @@ public async Task UpdateChannel(int channelId, string? name = null, string } } + /// + /// 获取渠道下的模型展示名列表。同一模型(忽略大小写)跨多个 binding 时显示为 + /// `model [channel/binding/protocol]`,单 binding 模型保持原名(blueprint §八-阶段3)。 + /// public async Task> GetModelsByChannelId(long channelId) { - var models = await DataContext.ChannelsWithModel + var rows = await DataContext.ChannelsWithModel + .Include(c => c.ApiBinding) + .Include(c => c.LLMChannel) .Where(c => c.LLMChannelId == channelId && !c.IsDeleted) - .Select(c => c.ModelName) .ToListAsync(); - return models; + + 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 => multiBindingNames.Contains(r.ModelName) ? FormatModelDisplay(r) : r.ModelName) + .ToList(); + } + + /// + /// 多 binding 展示格式:`model [channel/binding/protocol]`;legacy 行(无 binding)以 default/Provider 标注。 + /// + private static string FormatModelDisplay(ChannelWithModel row) { + var channelName = row.LLMChannel?.Name ?? row.LLMChannelId.ToString(); + var bindingLabel = row.ApiBindingId?.ToString() ?? "default"; + var protocolLabel = row.ApiBinding?.Protocol.ToString() + ?? row.LLMChannel?.Provider.ToString() + ?? "unknown"; + return $"{row.ModelName} [{channelName}/{bindingLabel}/{protocolLabel}]"; + } + + /// + /// 管理修复:确保每渠道恰好一个默认 binding(blueprint §六.8)。 + /// 无默认 binding 时按 legacy Provider/Gateway 映射补建(与迁移的 Protocol/AuthProfile 映射一致); + /// 多个 IsDefault 时保留 Id 最小者并降级其余(告警,稳定解析)。 + /// + private async Task EnsureDefaultBinding(LLMChannel channel) { + await DataContext.Entry(channel).Collection(c => c.Bindings).LoadAsync(); + var defaults = channel.Bindings.Where(b => b.IsDefault).OrderBy(b => b.Id).ToList(); + if (defaults.Count > 1) { + _logger.LogWarning("渠道 {ChannelId} 存在多个 IsDefault binding({Count} 个),保留 Id 最小者,其余降级(请管理员修复)", channel.Id, defaults.Count); + foreach (var other in defaults.Skip(1)) { + other.IsDefault = false; + } + } + if (defaults.Count >= 1) { + return defaults[0]; + } + + var (protocol, authProfile) = MapProviderToBinding(channel.Provider); + var binding = new LLMApiBinding { + LLMChannelId = channel.Id, + Endpoint = channel.Gateway, + Protocol = protocol, + AuthProfile = authProfile, + IsDefault = true + }; + DataContext.LLMApiBindings.Add(binding); + channel.Bindings.Add(binding); + await DataContext.SaveChangesAsync(); + _logger.LogInformation("为渠道 {ChannelName} 补建默认 binding(Endpoint={Endpoint}, Protocol={Protocol}, AuthProfile={AuthProfile})", channel.Name, channel.Gateway, protocol, authProfile); + return binding; + } + + /// + /// 设置(或创建)默认 binding,保证每渠道至多一个 IsDefault; + /// 并镜像 LLMChannel.Gateway/Provider,使旧二进制继续走默认协议(blueprint §七)。 + /// + public async Task SetDefaultBinding(int channelId, string endpoint, LlmProtocol protocol, LlmAuthProfile authProfile) { + try { + var channel = await DataContext.LLMChannels.Include(c => c.Bindings).FirstOrDefaultAsync(c => c.Id == channelId); + if (channel == null) { + return false; + } + + var defaults = channel.Bindings.Where(b => b.IsDefault).OrderBy(b => b.Id).ToList(); + var binding = defaults.FirstOrDefault(); + if (defaults.Count > 1) { + _logger.LogWarning("渠道 {ChannelId} 存在多个 IsDefault binding({Count} 个),保留 Id 最小者,其余降级(请管理员修复)", channelId, defaults.Count); + foreach (var other in defaults.Skip(1)) { + other.IsDefault = false; + } + } + if (binding == null) { + binding = new LLMApiBinding { LLMChannelId = channelId, IsDefault = true }; + DataContext.LLMApiBindings.Add(binding); + channel.Bindings.Add(binding); + } + + binding.Endpoint = endpoint; + binding.Protocol = protocol; + binding.AuthProfile = authProfile; + + // 镜像规则(blueprint §七):默认 binding 变更同步 Gateway/Provider,旧二进制继续可用 + channel.Gateway = endpoint; + channel.Provider = MapProtocolToProvider(protocol); + + await DataContext.SaveChangesAsync(); + return true; + } catch (Exception ex) { + _logger.LogError(ex, "设置渠道 {ChannelId} 默认 binding 失败", channelId); + return false; + } + } + + /// + /// 模型级协议覆盖:同一渠道同一模型(忽略大小写)至多一个 IsPreferred 行。 + /// 目标行必须已存在且未删除;已有其他 preferred 行时降级并告警(遵循 phase-2 resolver 的告警+稳定解析约定)。 + /// + public async Task SetModelPreferred(int channelId, string modelName, int bindingId) { + try { + var rows = await DataContext.ChannelsWithModel + .Where(m => m.LLMChannelId == channelId && !m.IsDeleted) + .ToListAsync(); + + var target = rows.FirstOrDefault(r => r.ApiBindingId == bindingId + && r.ModelName.Equals(modelName, StringComparison.OrdinalIgnoreCase)); + if (target == null) { + _logger.LogWarning("设置 preferred 失败:渠道 {ChannelId} 模型 {ModelName} binding {BindingId} 无可用行", channelId, modelName, bindingId); + return false; + } + + var others = rows.Where(r => r.IsPreferred && r.Id != target.Id + && r.ModelName.Equals(modelName, StringComparison.OrdinalIgnoreCase)).ToList(); + if (others.Count > 0) { + _logger.LogWarning("模型 {ModelName} 渠道 {ChannelId} 存在多个 IsPreferred 行({Count} 个),已降级其余行,保持稳定解析(请管理员修复)", modelName, channelId, others.Count + 1); + foreach (var other in others) { + other.IsPreferred = false; + } + } + + target.IsPreferred = true; + await DataContext.SaveChangesAsync(); + return true; + } catch (Exception ex) { + _logger.LogError(ex, "设置模型 {ModelName} preferred 失败", modelName); + return false; + } + } + + /// + /// channel Gateway/Provider 变更时同步默认 binding(镜像规则,blueprint §七)。 + /// 无默认 binding 时不创建(补建由 EnsureDefaultBinding 在 AddChannel/RefreshAllChannel 负责)。 + /// + private async Task SyncDefaultBindingAsync(LLMChannel channel) { + var defaultBinding = await DataContext.LLMApiBindings + .Where(b => b.LLMChannelId == channel.Id && b.IsDefault) + .OrderBy(b => b.Id) + .FirstOrDefaultAsync(); + if (defaultBinding == null) { + return; + } + defaultBinding.Endpoint = channel.Gateway; + (defaultBinding.Protocol, defaultBinding.AuthProfile) = MapProviderToBinding(channel.Provider); + } + + /// + /// OpenCode 目录识别:持久化 binding Endpoint 位于 opencode.ai/zen/* 空间(数据属性,非协议猜测,blueprint §四.1)。 + /// 更干净的标记需新增 binding 列(如 IsCatalogOnly),需要迁移,超出阶段3范围。 + /// + internal static bool IsOpenCodeBinding(LLMApiBinding? binding) { + return binding != null + && Uri.TryCreate(binding.Endpoint, UriKind.Absolute, out var uri) + && string.Equals(uri.Host, "opencode.ai", StringComparison.OrdinalIgnoreCase) + && uri.AbsolutePath.StartsWith("/zen/", StringComparison.OrdinalIgnoreCase); + } + + /// + /// legacy Provider → (Protocol, AuthProfile),与迁移 SQL 的映射一致(blueprint §七.4/.5)。 + /// + internal static (LlmProtocol Protocol, LlmAuthProfile AuthProfile) MapProviderToBinding(LLMProvider provider) { + return provider switch { + LLMProvider.OpenAI => (LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer), + LLMProvider.Ollama => (LlmProtocol.Ollama, LlmAuthProfile.None), + LLMProvider.Gemini => (LlmProtocol.Gemini, LlmAuthProfile.Bearer), + LLMProvider.MiniMax => (LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer), + LLMProvider.LMStudio => (LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer), + LLMProvider.Anthropic => (LlmProtocol.AnthropicMessages, LlmAuthProfile.AnthropicApiKey), + LLMProvider.ResponsesAPI => (LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer), + _ => (LlmProtocol.OpenAIChat, LlmAuthProfile.Bearer) + }; + } + + /// + /// Protocol → legacy Provider(SetDefaultBinding 镜像用;OpenAIChat 的 OpenAIChat→OpenAI 为通用默认)。 + /// + internal static LLMProvider MapProtocolToProvider(LlmProtocol protocol) { + return protocol switch { + LlmProtocol.OpenAIChat => LLMProvider.OpenAI, + LlmProtocol.OpenAIResponses => LLMProvider.ResponsesAPI, + LlmProtocol.AnthropicMessages => LLMProvider.Anthropic, + LlmProtocol.Ollama => LLMProvider.Ollama, + LlmProtocol.Gemini => LLMProvider.Gemini, + _ => LLMProvider.OpenAI + }; } } } diff --git a/TelegramSearchBot/Service/Manage/EditLLMConfService.cs b/TelegramSearchBot/Service/Manage/EditLLMConfService.cs index 7d8dca53..8566339e 100644 --- a/TelegramSearchBot/Service/Manage/EditLLMConfService.cs +++ b/TelegramSearchBot/Service/Manage/EditLLMConfService.cs @@ -355,11 +355,8 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration return (true, "找不到指定的渠道"); } - // 获取该渠道下的所有模型 - var channelModels = await DataContext.ChannelsWithModel - .Where(m => m.LLMChannelId == viewModelChannelId && !m.IsDeleted) - .Select(m => m.ModelName) - .ToListAsync(); + // 获取该渠道下的所有模型(多 binding 同模型显示为 `model [channel/binding/protocol]`,见 GetModelsByChannelId) + var channelModels = await Helper.GetModelsByChannelId(viewModelChannelId); var modelSb = new StringBuilder(); modelSb.AppendLine($"渠道 {viewModelChannel.Name} 下的模型列表:"); From b9d58071d261c990d539895a4a46cff2c49c7863 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Fri, 14 Aug 2026 21:28:32 +0800 Subject: [PATCH 2/2] Address review feedback: duplicate-row guards, repair persistence, Anthropic /v1 edge, resume legacy fallback, cancellation propagation, endpoint validation --- .../Service/AI/LLM/AnthropicService.cs | 7 ++++--- .../Service/AI/LLM/GeneralLLMService.cs | 10 ++++++++-- .../Service/AI/LLM/OllamaService.cs | 16 ++++++++++++++-- .../Service/AI/LLM/OpenAIResponsesService.cs | 13 ++++++++++--- .../Service/AI/LLM/OpenAIService.cs | 11 ++++++++--- .../Service/Manage/EditLLMConfHelper.cs | 15 ++++++++++++--- 6 files changed, 56 insertions(+), 16 deletions(-) diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs index e7964d6c..e8d20f78 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs @@ -101,10 +101,11 @@ private AnthropicClient CreateClient(LLMChannel channel, LLMApiBinding? binding if (!string.IsNullOrWhiteSpace(endpoint)) { // Binding URL 已含 /v1(如 https://opencode.ai/zen/v1),SDK 会再追加 /v1/messages; // 剥离尾部 /v1 使 SDK 追加后命中精确 binding 路径。legacy channel.Gateway 保持字节一致。 - if (binding != null && endpoint.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) { - endpoint = endpoint.Substring(0, endpoint.Length - 3); + var trimmed = endpoint.TrimEnd('/'); + if (binding != null && trimmed.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) { + trimmed = trimmed.Substring(0, trimmed.Length - 3); } - options.BaseUrl = endpoint.TrimEnd('/'); + options.BaseUrl = trimmed; } return new AnthropicClient(options); } diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs index 4d3e14d2..4a39f57d 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/GeneralLLMService.cs @@ -147,7 +147,13 @@ public async IAsyncEnumerable ResumeFromSnapshotAsync( .ToListAsync(); var route = LlmRouteResolver.Resolve(channel, snapshot.ModelName, modelRows, _logger); if (route == null) { - _logger.LogError("Cannot resume: model {Model} has no route on channel {ChannelId}", snapshot.ModelName, channel.Id); + // 六.8 legacy 回退:模型行缺失/软删时按渠道 Provider/Gateway 继续,不丢弃排队中的续聊 + _logger.LogWarning("Cannot resume: model {Model} has no route on channel {ChannelId}, falling back to legacy provider route", snapshot.ModelName, channel.Id); + var legacyService = _LLMFactory.GetLLMService(channel.Provider); + await foreach (var item in legacyService.ResumeFromSnapshotAsync(snapshot, channel, null, executionContext, cancellationToken) + .WithCancellation(cancellationToken)) { + yield return item; + } yield break; } @@ -223,7 +229,7 @@ orderby s.Priority descending } // 6. 按解析路由执行 - await foreach (var e in operation(service, channel, route.Binding, new CancellationToken())) { + await foreach (var e in operation(service, channel, route.Binding, cancellationToken)) { yield return e; } yield break; diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs index 12c70d52..2e0f46fe 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs @@ -446,8 +446,14 @@ public async Task GenerateEmbeddingsAsync(string text, string modelName modelName = "bge-m3"; } + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + if (channel == null || string.IsNullOrWhiteSpace(endpoint)) { + _logger.LogError("{ServiceName}: Channel or Gateway is not configured.", ServiceName); + throw new InvalidOperationException($"Error: {ServiceName} channel/gateway is not configured."); + } + var httpClient = _httpClientFactory?.CreateClient() ?? new HttpClient(); - httpClient.BaseAddress = new Uri(LlmBindingSupport.ResolveEndpoint(channel, binding)); + httpClient.BaseAddress = new Uri(endpoint); var ollama = new OllamaApiClient(httpClient, modelName); if (!await CheckAndPullModelAsync(ollama, modelName)) { @@ -479,8 +485,14 @@ public async Task AnalyzeImageAsync(string photoPath, string modelName, prompt = string.IsNullOrWhiteSpace(prompt) ? GeneralLLMService.DefaultAltPhotoPrompt : prompt; + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + if (channel == null || string.IsNullOrWhiteSpace(endpoint)) { + _logger.LogError("{ServiceName}: Channel or Gateway is not configured.", ServiceName); + return $"Error: {ServiceName} channel/gateway is not configured."; + } + var httpClient = _httpClientFactory?.CreateClient() ?? new HttpClient(); - httpClient.BaseAddress = new Uri(LlmBindingSupport.ResolveEndpoint(channel, binding)); + httpClient.BaseAddress = new Uri(endpoint); var ollama = new OllamaApiClient(httpClient, modelName); ollama.SelectedModel = modelName; var chat = new Chat(ollama); diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs index 2a01ec23..e603ff6c 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs @@ -753,13 +753,20 @@ public async Task GenerateEmbeddingsAsync(string text, string modelName } public async Task GenerateEmbeddingsAsync(string text, string modelName, LLMChannel channel, LLMApiBinding binding) { + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + var apiKey = LlmBindingSupport.ResolveApiKey(channel, binding); + if (channel == null || string.IsNullOrWhiteSpace(endpoint) || (binding?.AuthProfile != LlmAuthProfile.None && string.IsNullOrWhiteSpace(apiKey))) { + _logger.LogError("{ServiceName}: Channel, Gateway, or ApiKey is not configured.", ServiceName); + throw new InvalidOperationException($"Error: {ServiceName} channel/gateway/apikey is not configured."); + } + using var httpClient = _httpClientFactory.CreateClient(); var clientOptions = new OpenAIClientOptions { - Endpoint = new Uri(LlmBindingSupport.ResolveEndpoint(channel, binding)), + Endpoint = new Uri(endpoint), Transport = new HttpClientPipelineTransport(httpClient), }; - var apiKey = new ApiKeyCredential(LlmBindingSupport.ResolveApiKey(channel, binding)); - OpenAIClient client = new(apiKey, clientOptions); + var credential = new ApiKeyCredential(apiKey); + OpenAIClient client = new(credential, clientOptions); try { var embeddingClient = client.GetEmbeddingClient(modelName); diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs index 1a68ae39..129c0f53 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs @@ -1997,16 +1997,21 @@ public async Task GenerateEmbeddingsAsync(string text, string modelName } public async Task GenerateEmbeddingsAsync(string text, string modelName, LLMChannel channel, LLMApiBinding binding) { - + var endpoint = LlmBindingSupport.ResolveEndpoint(channel, binding); + var apiKey = LlmBindingSupport.ResolveApiKey(channel, binding); + if (channel == null || string.IsNullOrWhiteSpace(endpoint) || (binding?.AuthProfile != LlmAuthProfile.None && string.IsNullOrWhiteSpace(apiKey))) { + _logger.LogError("{ServiceName}: Channel, Gateway, or ApiKey is not configured.", ServiceName); + throw new InvalidOperationException($"Error: {ServiceName} channel/gateway/apikey is not configured."); + } using var httpClient = _httpClientFactory.CreateClient(); var clientOptions = new OpenAIClientOptions { - Endpoint = new Uri(NormalizeOpenAIEndpoint(channel, LlmBindingSupport.ResolveEndpoint(channel, binding))), + Endpoint = new Uri(NormalizeOpenAIEndpoint(channel, endpoint)), Transport = new HttpClientPipelineTransport(httpClient), }; - var apikey = new ApiKeyCredential(LlmBindingSupport.ResolveApiKey(channel, binding)); + var apikey = new ApiKeyCredential(apiKey); OpenAIClient client = new(apikey, clientOptions); try { diff --git a/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs b/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs index 190d9066..cb1c7e5a 100644 --- a/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs +++ b/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs @@ -161,9 +161,13 @@ public async Task RefreshAllChannel() { _logger.LogInformation("通道 {ChannelName} 标记删除消失的模型 {ModelName}", channel.Name, record.ModelName); } - // 添加全新的模型(同 binding 内忽略大小写去重,blueprint §七.6) + // 添加全新的模型(同 binding 内忽略大小写去重,blueprint §七.6); + // 去重覆盖同 binding 的全部行(任意 AuthorizationSource,含 Manual); + // null-FK 旧库行按 §六.8 也解释为默认 binding 路由。 + // 防止手工行与刷新插入的 Discovered 行重复。 var toAdd = modelSet - .Where(m => !scopedRecords.Any(r => r.ModelName.Equals(m, StringComparison.OrdinalIgnoreCase))) + .Where(m => !existingRecords.Any(r => (r.ApiBindingId ?? defaultBinding?.Id) == defaultBinding?.Id + && r.ModelName.Equals(m, StringComparison.OrdinalIgnoreCase))) .Select(m => new ChannelWithModel { LLMChannelId = channel.Id, ModelName = m, @@ -323,6 +327,9 @@ public async Task AddModelWithChannel(int channelId, List modelNam return false; } + // 忽略大小写去重,批内同名(如 "gpt-4o"+"GPT-4O")只建一行(blueprint §七.6) + modelNames = modelNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + // 管理员手工添加 = Manual 授权(blueprint §四.5);新行关联默认 binding var defaultBinding = await DataContext.LLMApiBindings .Where(b => b.LLMChannelId == channelId && b.IsDefault) @@ -523,6 +530,7 @@ private static string FormatModelDisplay(ChannelWithModel row) { foreach (var other in defaults.Skip(1)) { other.IsDefault = false; } + await DataContext.SaveChangesAsync(); } if (defaults.Count >= 1) { return defaults[0]; @@ -643,7 +651,8 @@ internal static bool IsOpenCodeBinding(LLMApiBinding? binding) { return binding != null && Uri.TryCreate(binding.Endpoint, UriKind.Absolute, out var uri) && string.Equals(uri.Host, "opencode.ai", StringComparison.OrdinalIgnoreCase) - && uri.AbsolutePath.StartsWith("/zen/", StringComparison.OrdinalIgnoreCase); + && (string.Equals(uri.AbsolutePath, "/zen", StringComparison.OrdinalIgnoreCase) + || uri.AbsolutePath.StartsWith("/zen/", StringComparison.OrdinalIgnoreCase)); } ///