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