diff --git a/Docs/Bot_Commands_User_Guide.md b/Docs/Bot_Commands_User_Guide.md
index 64efde7d..0b9797c3 100644
--- a/Docs/Bot_Commands_User_Guide.md
+++ b/Docs/Bot_Commands_User_Guide.md
@@ -154,6 +154,8 @@
4. 机器人回复: `请输入 <预设名> 的 API Key(本地/免 Key 服务发送 - 跳过):`
5. 管理员发送 API Key(或 `-`)
6. 机器人回复: `渠道创建成功(ID: x)`,并自动预置默认模型;自定义 endpoint / 模型仍可用 `新建渠道` / `添加模型` 修改。
+ * **预设目录数据化**:预设来自内置 `providers.json`(每 6 小时的渠道刷新会让模型表跟随供应商最新状态);如需增改服务商,可仿照内置文件在 `%LOCALAPPDATA%/TelegramSearchBot/providers.json` 放置覆盖文件(含 `generatedAt` 与 `presets`),重启后优先生效,覆盖文件损坏会自动回退内置目录。
+ * **多协议网关**:OpenCode Zen 按模型名自动分派协议(`claude-*`→Anthropic、`gpt-*`/`grok-*`→Responses、`gemini-*`→Gemini、其余→Chat Completions);OpenCode Go 为订阅网关,预置官方模型并通过 `/models` 只增不删地同步目录。
* **例如,使用 `新建渠道` 指令的交互流程可能如下:**
1. 管理员发送: `新建渠道`
2. 机器人回复: `请输入渠道的名称`
@@ -180,7 +182,7 @@
1. 管理员发送: `编辑渠道`
2. 机器人回复: (列出现有渠道) `请选择要编辑的渠道ID:`
3. 管理员发送: (选择一个渠道ID,例如 `1`)
- 4. 机器人回复: (列出可编辑字段) `请选择要编辑的字段:\n1. 名称 (当前值)\n2. 地址 (当前值)\n3. 类型 (当前值)\n4. API Key\n5. 最大并行数量 (当前值)\n6. 优先级 (当前值)`
+ 4. 机器人回复: (列出可编辑字段) `请选择要编辑的字段:\n1. 名称 (当前值)\n2. 地址 (当前值)\n3. 类型 (当前值)\n4. API Key\n5. 最大并行数量 (当前值)\n6. 优先级 (当前值)\n7. 协议绑定`
5. 管理员发送: (选择字段编号)
6. **如果选择 `1`, `2`, `4`, `5`, `6`**:
- 机器人回复: `请输入新的值:`
@@ -189,9 +191,14 @@
6. **如果选择 `3` (类型)**:
- 机器人回复: `请选择渠道类型:\n1. OpenAI\n2. Ollama`
- 管理员发送: (输入选项编号,例如 `1`)
+ 6. **如果选择 `7` (协议绑定)**(多协议网关用,如 OpenCode Zen/Go):
+ - 机器人回复: (列出绑定) `渠道 x 的协议绑定:\n3. https://opencode.ai/zen/v1 (OpenAIChat/Bearer) [默认]\n...`
+ - 管理员发送: 绑定ID(设为默认)或 `0`(新增绑定)
+ - 新增时依次输入:端点地址 → 线协议(OpenAIChat/OpenAIResponses/AnthropicMessages/Ollama/Gemini)→ 认证方式(Bearer/AnthropicApiKey/None)
+ - 机器人回复: `绑定创建成功(ID: x,协议/认证)`
7. 管理员发送: (输入新值或选择编号)
8. 机器人回复: `更新成功` (或失败信息)
- * **`移除模型` 指令的交互流程示例:**
+ * **`移除模型` 指令的交互流程示例:**(按模型行删除:多 binding 下同名模型会带 `[渠道/binding/协议]` 标注,删错行不会发生)
1. 管理员发送: `移除模型`
2. 机器人回复: (列出现有渠道) `请选择要移除模型的渠道ID:`
3. 管理员发送: (选择一个渠道ID,例如 `1`)
diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/LlmProviderCatalogLoaderTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/LlmProviderCatalogLoaderTests.cs
new file mode 100644
index 00000000..522b0b8c
--- /dev/null
+++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/LlmProviderCatalogLoaderTests.cs
@@ -0,0 +1,96 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+using TelegramSearchBot.Model.AI;
+using TelegramSearchBot.Service.AI.LLM;
+using Xunit;
+
+namespace TelegramSearchBot.LLM.Test.Service.AI.LLM {
+ public class LlmProviderCatalogLoaderTests {
+ private static string TempPath(string name) {
+ var dir = Path.Combine(Path.GetTempPath(), "tsb-providers-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ return Path.Combine(dir, name);
+ }
+
+ [Fact]
+ public void BuiltInDocument_LoadsAllPresets_WhenOverrideMissing() {
+ var missing = TempPath("providers.json");
+
+ var doc = LlmProviderCatalogLoader.Load(overridePath: missing);
+
+ Assert.Equal("builtin", doc.Source);
+ Assert.Contains("不存在", doc.Warning);
+ Assert.Equal(11, doc.Presets.Count);
+ Assert.NotNull(doc.GeneratedAt);
+ Assert.NotNull(doc.Presets.FirstOrDefault(p => p.Id == "opencode-go"));
+ }
+
+ [Fact]
+ public void BuiltInDocument_KeepsOpenCodeBindingsAndRules() {
+ var go = LlmProviderCatalog.FindById("opencode-go")!;
+
+ Assert.Single(go.Bindings!);
+ Assert.Equal(LlmProtocol.OpenAIResponses, go.Bindings![0].Protocol);
+ Assert.Equal("responses", go.ModelBindingRules.ResolveBindingId("grok-4.6"));
+ Assert.True(go.CatalogIsEntitlement);
+ Assert.Equal("https://opencode.ai/zen/go/v1", go.DefaultGateway);
+ }
+
+ [Fact]
+ public void OverrideFile_WinsOverBuiltIn() {
+ var path = TempPath("providers.json");
+ File.WriteAllText(path, """
+ {
+ "generatedAt": "2030-01-01T00:00:00Z",
+ "presets": [
+ { "id": "custom", "displayName": "Custom", "provider": "OpenAI", "defaultGateway": "https://example.com/v1",
+ "defaultModels": ["m1"], "requiresApiKey": true,
+ "bindings": [ { "id": "responses", "protocol": "OpenAIResponses", "authProfile": "Bearer" } ],
+ "modelBindingRules": [ { "bindingId": "responses", "prefixes": ["gpt-"] } ] }
+ ]
+ }
+ """);
+
+ var doc = LlmProviderCatalogLoader.Load(overridePath: path);
+
+ Assert.StartsWith("file:", doc.Source);
+ var preset = Assert.Single(doc.Presets);
+ Assert.Equal("custom", preset.Id);
+ Assert.Equal("responses", preset.ModelBindingRules.ResolveBindingId("gpt-x"));
+ Assert.Equal(LlmProtocol.OpenAIResponses, preset.Bindings![0].Protocol);
+ Assert.Equal(LlmAuthProfile.Bearer, preset.Bindings![0].AuthProfile);
+ }
+
+ [Fact]
+ public void InvalidOverride_FallsBackToBuiltInStream_WithWarning() {
+ var path = TempPath("providers.json");
+ File.WriteAllText(path, "{ this is not valid json");
+
+ using var builtIn = new MemoryStream(Encoding.UTF8.GetBytes("""
+ { "generatedAt": "2026-01-01T00:00:00Z", "presets": [ { "id": "fallback", "displayName": "Fallback", "provider": "OpenAI", "defaultGateway": null, "defaultModels": [], "requiresApiKey": false } ] }
+ """));
+
+ var doc = LlmProviderCatalogLoader.Load(overridePath: path, builtIn: builtIn);
+
+ Assert.Equal("builtin", doc.Source);
+ Assert.Contains("回退", doc.Warning);
+ Assert.Equal("fallback", Assert.Single(doc.Presets).Id);
+ }
+
+ [Fact]
+ public void EmptyPresetList_InOverride_IsRejected() {
+ var path = TempPath("providers.json");
+ File.WriteAllText(path, """{ "presets": [] }""");
+ using var builtIn = new MemoryStream(Encoding.UTF8.GetBytes("""
+ { "presets": [ { "id": "fallback", "displayName": "Fallback", "provider": "OpenAI", "defaultGateway": null, "defaultModels": [], "requiresApiKey": false } ] }
+ """));
+
+ var doc = LlmProviderCatalogLoader.Load(overridePath: path, builtIn: builtIn);
+
+ Assert.Equal("fallback", Assert.Single(doc.Presets).Id);
+ Assert.NotNull(doc.Warning);
+ }
+ }
+}
diff --git a/TelegramSearchBot.LLM/Providers/providers.json b/TelegramSearchBot.LLM/Providers/providers.json
new file mode 100644
index 00000000..b9114b4b
--- /dev/null
+++ b/TelegramSearchBot.LLM/Providers/providers.json
@@ -0,0 +1,125 @@
+{
+ "generatedAt": "2026-09-16T00:00:00Z",
+ "presets": [
+ {
+ "id": "anthropic",
+ "displayName": "Anthropic 官方",
+ "provider": "Anthropic",
+ "defaultGateway": "https://api.anthropic.com",
+ "defaultModels": ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"],
+ "requiresApiKey": true
+ },
+ {
+ "id": "openai",
+ "displayName": "OpenAI 官方 (Chat Completions)",
+ "provider": "OpenAI",
+ "defaultGateway": "https://api.openai.com/v1",
+ "defaultModels": ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra"],
+ "requiresApiKey": true
+ },
+ {
+ "id": "openai-responses",
+ "displayName": "OpenAI 官方 (Responses API)",
+ "provider": "ResponsesAPI",
+ "defaultGateway": "https://api.openai.com/v1",
+ "defaultModels": ["gpt-6-astra", "gpt-5.6-sol"],
+ "requiresApiKey": true
+ },
+ {
+ "id": "gemini",
+ "displayName": "Google Gemini",
+ "provider": "Gemini",
+ "defaultGateway": "https://generativelanguage.googleapis.com",
+ "defaultModels": ["gemini-3.8-flash", "gemini-3.1-pro-preview", "gemini-3.5-flash"],
+ "requiresApiKey": true
+ },
+ {
+ "id": "minimax",
+ "displayName": "MiniMax",
+ "provider": "MiniMax",
+ "defaultGateway": "https://api.minimaxi.com/v1",
+ "defaultModels": ["MiniMax-M3", "MiniMax-M2.7"],
+ "requiresApiKey": true
+ },
+ {
+ "id": "ollama",
+ "displayName": "本地 Ollama",
+ "provider": "Ollama",
+ "defaultGateway": "http://localhost:11434",
+ "defaultModels": [],
+ "requiresApiKey": false,
+ "notes": "本地服务无需 API Key;模型通过 `添加模型` 手动添加或自动发现。"
+ },
+ {
+ "id": "lmstudio",
+ "displayName": "本地 LM Studio",
+ "provider": "LMStudio",
+ "defaultGateway": "http://localhost:1234/v1",
+ "defaultModels": [],
+ "requiresApiKey": false,
+ "notes": "本地服务无需 API Key;模型通过 `添加模型` 手动添加。"
+ },
+ {
+ "id": "deepseek",
+ "displayName": "DeepSeek (OpenAI 兼容)",
+ "provider": "OpenAI",
+ "defaultGateway": "https://api.deepseek.com/v1",
+ "defaultModels": ["deepseek-flash", "deepseek-v4-pro"],
+ "requiresApiKey": true
+ },
+ {
+ "id": "moonshot",
+ "displayName": "Moonshot Kimi (OpenAI 兼容)",
+ "provider": "OpenAI",
+ "defaultGateway": "https://api.moonshot.cn/v1",
+ "defaultModels": ["kimi-k3", "kimi-k2.7-code"],
+ "requiresApiKey": true
+ },
+ {
+ "id": "opencode-zen",
+ "displayName": "OpenCode Zen (官方订阅目录)",
+ "provider": "OpenAI",
+ "defaultGateway": "https://opencode.ai/zen/v1",
+ "defaultModels": [],
+ "requiresApiKey": true,
+ "notes": "多协议网关(claude-*→Anthropic、gpt-*/grok-*→Responses、gemini-*→Google、其余→Chat Completions);按量计费,目录不自动创建,授权模型请用 `添加模型`。",
+ "bindings": [
+ { "id": "anthropic", "protocol": "AnthropicMessages", "authProfile": "AnthropicApiKey", "endpointSuffix": "/v1" },
+ { "id": "responses", "protocol": "OpenAIResponses", "authProfile": "Bearer", "endpointSuffix": "/v1" },
+ { "id": "google", "protocol": "Gemini", "authProfile": "Bearer", "endpointSuffix": "/v1" }
+ ],
+ "modelBindingRules": [
+ { "bindingId": "anthropic", "prefixes": ["claude-"] },
+ { "bindingId": "responses", "prefixes": ["gpt-", "grok-", "o3", "o4"] },
+ { "bindingId": "google", "prefixes": ["gemini-"] }
+ ]
+ },
+ {
+ "id": "opencode-go",
+ "displayName": "OpenCode Go (订阅网关)",
+ "provider": "OpenAI",
+ "defaultGateway": "https://opencode.ai/zen/go/v1",
+ "defaultModels": [
+ "grok-4.6", "gpt-5.6-luna",
+ "glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1",
+ "kimi-k3", "kimi-k2.7-code", "kimi-k2.6",
+ "longcat-2.0",
+ "deepseek-v4.1-flash", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp",
+ "minimax-m3", "minimax-m2.7",
+ "mimo-v2.5", "mimo-v2.5-pro",
+ "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus",
+ "muse-spark-1.3-contributor", "muse-spark-1.2-contributor",
+ "hy4-preview", "hy3"
+ ],
+ "requiresApiKey": true,
+ "notes": "OpenAI 兼容订阅网关(/responses + /chat/completions),自动携带 x-opencode-session;订阅覆盖目录模型,刷新会同步目录。自建网关请用 `新建渠道`。",
+ "bindings": [
+ { "id": "responses", "protocol": "OpenAIResponses", "authProfile": "Bearer", "endpointSuffix": "/v1" }
+ ],
+ "modelBindingRules": [
+ { "bindingId": "responses", "prefixes": ["grok-", "gpt-"] }
+ ],
+ "catalogIsEntitlement": true
+ }
+ ]
+}
diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs
index 582895de..bbe0929a 100644
--- a/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs
+++ b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs
@@ -4,7 +4,7 @@
using TelegramSearchBot.Model.AI;
namespace TelegramSearchBot.Service.AI.LLM {
- /// A built-in provider preset: one code-defined entry of the provider catalog.
+ /// A built-in provider preset: one entry of the provider catalog.
public sealed record LlmProviderPreset(
string Id,
string DisplayName,
@@ -53,102 +53,22 @@ public static class LlmModelBindingRules {
}
///
- /// Code-defined provider catalog (pi-style): common providers ship preconfigured so the bot
- /// admin only picks one and enters an API key. Custom endpoints are still supported by the
- /// existing manual channel flow (新建渠道) and LLMApiBinding overrides.
+ /// Provider catalog (pi-style): presets ship as data (`Providers/providers.json`, embedded) so providers and
+ /// model lists can be updated without code changes — a user override at
+ /// `%LOCALAPPDATA%/TelegramSearchBot/providers.json` wins over the built-in copy.
+ /// Custom endpoints are still supported by the manual channel flow (新建渠道) and LLMApiBinding overrides.
///
public static class LlmProviderCatalog {
- public static readonly IReadOnlyList Presets = new[] {
- new LlmProviderPreset(
- "anthropic", "Anthropic 官方", LLMProvider.Anthropic,
- "https://api.anthropic.com",
- new[] { "claude-sonnet-4-5", "claude-opus-4-1", "claude-haiku-4-5" },
- RequiresApiKey: true),
- new LlmProviderPreset(
- "openai", "OpenAI 官方 (Chat Completions)", LLMProvider.OpenAI,
- "https://api.openai.com/v1",
- new[] { "gpt-4o", "gpt-4o-mini", "gpt-4.1" },
- RequiresApiKey: true),
- new LlmProviderPreset(
- "openai-responses", "OpenAI 官方 (Responses API)", LLMProvider.ResponsesAPI,
- "https://api.openai.com/v1",
- new[] { "gpt-4o", "gpt-4.1" },
- RequiresApiKey: true),
- new LlmProviderPreset(
- "gemini", "Google Gemini", LLMProvider.Gemini,
- "https://generativelanguage.googleapis.com",
- new[] { "gemini-2.0-flash", "gemini-2.5-pro" },
- RequiresApiKey: true),
- new LlmProviderPreset(
- "minimax", "MiniMax", LLMProvider.MiniMax,
- "https://api.minimax.chat/v1",
- new[] { "MiniMax-Text-01", "abab6.5s-chat" },
- RequiresApiKey: true),
- new LlmProviderPreset(
- "ollama", "本地 Ollama", LLMProvider.Ollama,
- "http://localhost:11434",
- Array.Empty(),
- RequiresApiKey: false,
- Notes: "本地服务无需 API Key;模型通过 `添加模型` 手动添加或自动发现。"),
- new LlmProviderPreset(
- "lmstudio", "本地 LM Studio", LLMProvider.LMStudio,
- "http://localhost:1234/v1",
- Array.Empty(),
- RequiresApiKey: false,
- Notes: "本地服务无需 API Key;模型通过 `添加模型` 手动添加。"),
- new LlmProviderPreset(
- "deepseek", "DeepSeek (OpenAI 兼容)", LLMProvider.OpenAI,
- "https://api.deepseek.com/v1",
- new[] { "deepseek-chat", "deepseek-reasoner" },
- RequiresApiKey: true),
- new LlmProviderPreset(
- "moonshot", "Moonshot Kimi (OpenAI 兼容)", LLMProvider.OpenAI,
- "https://api.moonshot.cn/v1",
- new[] { "kimi-k2-0711-preview", "moonshot-v1-128k" },
- RequiresApiKey: true),
- // OpenCode 网关不是单协议服务商:Zen 按模型分属 Anthropic/OpenAI/Google 协议,
- // Go 只有 Responses + Chat Completions。预设建渠道时按 ModelBindingRules 拆 binding。
- new LlmProviderPreset(
- "opencode-zen", "OpenCode Zen (官方订阅目录)", LLMProvider.OpenAI,
- "https://opencode.ai/zen/v1",
- Array.Empty(),
- RequiresApiKey: true,
- Notes: "多协议网关(claude-*→Anthropic、gpt-*/grok-*→Responses、gemini-*→Google、其余→Chat Completions);按量计费,目录不自动创建,授权模型请用 `添加模型`。",
- Bindings: new[] {
- new LlmPresetBinding("anthropic", LlmProtocol.AnthropicMessages, LlmAuthProfile.AnthropicApiKey),
- new LlmPresetBinding("responses", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer),
- new LlmPresetBinding("google", LlmProtocol.Gemini, LlmAuthProfile.Bearer)
- },
- ModelBindingRules: new[] {
- new LlmModelBindingRule("anthropic", "claude-"),
- new LlmModelBindingRule("responses", "gpt-", "grok-", "o3", "o4"),
- new LlmModelBindingRule("google", "gemini-")
- }),
- new LlmProviderPreset(
- "opencode-go", "OpenCode Go (订阅网关)", LLMProvider.OpenAI,
- "https://opencode.ai/zen/go/v1",
- new[] {
- "grok-4.6", "gpt-5.6-luna",
- "glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1",
- "kimi-k3", "kimi-k2.7-code", "kimi-k2.6",
- "longcat-2.0",
- "deepseek-v4.1-flash", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp",
- "minimax-m3", "minimax-m2.7",
- "mimo-v2.5", "mimo-v2.5-pro",
- "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus",
- "muse-spark-1.3-contributor", "muse-spark-1.2-contributor",
- "hy4-preview", "hy3"
- },
- RequiresApiKey: true,
- Notes: "OpenAI 兼容订阅网关(/responses + /chat/completions),自动携带 x-opencode-session;订阅覆盖目录模型,刷新会同步目录。自建网关请用 `新建渠道`。",
- Bindings: new[] {
- new LlmPresetBinding("responses", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer)
- },
- ModelBindingRules: new[] {
- new LlmModelBindingRule("responses", "grok-", "gpt-")
- },
- CatalogIsEntitlement: true),
- };
+ private static readonly Lazy Document =
+ new(() => LlmProviderCatalogLoader.Load(), LazyThreadSafetyMode.ExecutionAndPublication);
+
+ public static IReadOnlyList Presets => Document.Value.Presets;
+
+ /// 内置/覆盖目录的数据版本(providers.json 的 generatedAt)。
+ public static DateTimeOffset? GeneratedAt => Document.Value.GeneratedAt;
+
+ /// 目录来源(`builtin` 或 `file:`),便于排障。
+ public static string Source => Document.Value.Source;
public static LlmProviderPreset? FindById(string id) =>
Presets.FirstOrDefault(p => p.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalogLoader.cs b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalogLoader.cs
new file mode 100644
index 00000000..1332d85e
--- /dev/null
+++ b/TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalogLoader.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using TelegramSearchBot.Common;
+
+namespace TelegramSearchBot.Service.AI.LLM {
+ /// 加载结果:预设列表 + 数据版本 + 来源(便于日志与测试)。
+ public sealed record LlmProviderCatalogDocument(
+ DateTimeOffset? GeneratedAt,
+ IReadOnlyList Presets,
+ string Source,
+ string? Warning = null);
+
+ ///
+ /// providers.json 加载器:优先用户覆盖 `%LOCALAPPDATA%/TelegramSearchBot/providers.json`
+ /// (不改代码、不发版即可增改服务商),其次程序集内置资源;
+ /// 覆盖文件损坏/为空时回退内置并在 Warning 中说明,绝不因目录问题启动失败。
+ ///
+ public static class LlmProviderCatalogLoader {
+ public const string OverrideFileName = "providers.json";
+ public const string BuiltInResourceName = "TelegramSearchBot.Service.AI.LLM.providers.json";
+
+ private static readonly JsonSerializerOptions SerializerOptions = new() {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ Converters = { new JsonStringEnumConverter() }
+ };
+
+ public static LlmProviderCatalogDocument Load(string? overridePath = null, Stream? builtIn = null) {
+ var path = overridePath ?? ResolveDefaultOverridePath();
+ string? warning = null;
+
+ if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) {
+ try {
+ return Parse(File.ReadAllText(path), $"file:{path}");
+ } catch (Exception ex) {
+ warning = $"providers.json 覆盖文件解析失败,回退内置目录: {ex.Message}";
+ }
+ } else if (overridePath != null) {
+ warning = $"providers.json 覆盖文件不存在,使用内置目录: {overridePath}";
+ }
+
+ if (builtIn == null) {
+ using var stream = typeof(LlmProviderCatalogLoader).Assembly.GetManifestResourceStream(BuiltInResourceName)
+ ?? throw new InvalidOperationException($"内置预设目录资源缺失: {BuiltInResourceName}");
+ using var reader = new StreamReader(stream);
+ return Parse(reader.ReadToEnd(), "builtin", warning);
+ }
+
+ builtIn.Position = 0;
+ using (var reader = new StreamReader(builtIn, leaveOpen: true)) {
+ return Parse(reader.ReadToEnd(), "builtin", warning);
+ }
+ }
+
+ /// Env.WorkDir 需要 Config.json,测试/无配置环境下退回空路径(只用内置目录)。
+ private static string ResolveDefaultOverridePath() {
+ try {
+ return Path.Combine(Env.WorkDir, OverrideFileName);
+ } catch {
+ return string.Empty;
+ }
+ }
+
+ private static LlmProviderCatalogDocument Parse(string json, string source, string? warning = null) {
+ var file = JsonSerializer.Deserialize(json, SerializerOptions)
+ ?? throw new InvalidOperationException("预设目录内容为空");
+ if (file.Presets == null || file.Presets.Count == 0) {
+ throw new InvalidOperationException("预设目录里没有任何 preset");
+ }
+
+ return new LlmProviderCatalogDocument(file.GeneratedAt, file.Presets, source, warning);
+ }
+
+ private sealed record CatalogFile(DateTimeOffset? GeneratedAt, List Presets);
+ }
+}
diff --git a/TelegramSearchBot.LLM/TelegramSearchBot.LLM.csproj b/TelegramSearchBot.LLM/TelegramSearchBot.LLM.csproj
index 87206f1d..986e4bad 100644
--- a/TelegramSearchBot.LLM/TelegramSearchBot.LLM.csproj
+++ b/TelegramSearchBot.LLM/TelegramSearchBot.LLM.csproj
@@ -1,35 +1,42 @@
-
-
-
- net10.0
- enable
- warnings
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ net10.0
+ enable
+ warnings
+
+
+
+
+
+
+
+
+
+
+ TelegramSearchBot.Service.AI.LLM.providers.json
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs b/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs
index 705436b4..1a5f94c0 100644
--- a/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs
+++ b/TelegramSearchBot.Test/Manage/EditLLMConfHelperTest.cs
@@ -496,9 +496,8 @@ private async Task SeedChannelWithDefaultBinding(int id, string n
[Fact]
public async Task RefreshAllChannel_OpenCodeBinding_CreatesNoRowsAndSoftDeletesNothing() {
- // Arrange: OpenCode 默认 binding(opencode.ai/zen/* 空间)——目录不是授权快照,
- // 刷新不得创建行、不得软删/复活任何行(blueprint §四.1/.5)
- const string openCodeEndpoint = "https://opencode.ai/zen/go/v1/chat/completions";
+ // Arrange: OpenCode Zen 是按量计费目录(非授权快照),刷新不得创建行、不得软删/复活任何行(blueprint §四.1/.5)
+ const string openCodeEndpoint = "https://opencode.ai/zen/v1/chat/completions";
var binding = await SeedChannelWithDefaultBinding(20, "OpenCode", LLMProvider.OpenAI,
gateway: openCodeEndpoint, endpoint: openCodeEndpoint);
await _context.ChannelsWithModel.AddRangeAsync(new[] {
@@ -738,6 +737,103 @@ public async Task SetModelPreferred_UnknownRowRejected() {
Assert.False(ok);
}
+ /// OpenCode Go 是订阅网关(目录即授权):只增不删;既有行/手工行不被覆盖或删除。
+ [Fact]
+ public async Task RefreshAllChannel_OpenCodeGo_SyncsCatalogAddOnly() {
+ // Arrange
+ const string goEndpoint = "https://opencode.ai/zen/go/v1";
+ var binding = await SeedChannelWithDefaultBinding(40, "OpenCode Go", LLMProvider.OpenAI,
+ gateway: goEndpoint, endpoint: goEndpoint);
+ await _context.ChannelsWithModel.AddRangeAsync(new[] {
+ new ChannelWithModel { LLMChannelId = 40, ModelName = "existing-manual", IsDeleted = false, AuthorizationSource = AuthorizationSource.Manual },
+ new ChannelWithModel { LLMChannelId = 40, ModelName = "restored-discovered", IsDeleted = true, AuthorizationSource = AuthorizationSource.Discovered, ApiBindingId = binding.Id },
+ new ChannelWithModel { LLMChannelId = 40, ModelName = "catalog-dropped", IsDeleted = false, AuthorizationSource = AuthorizationSource.Discovered, ApiBindingId = binding.Id }
+ });
+ await _context.SaveChangesAsync();
+
+ _openAIServiceMock.Setup(o => o.GetAllModels(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new List { "new-model", "existing-manual", "restored-discovered" });
+
+ // Act
+ await _helper.RefreshAllChannel();
+
+ // Assert: 新模型以 Discovered 行加入;既有行不重复;软删行恢复;目录里没有的行不被软删
+ var rows = await _context.ChannelsWithModel.Where(m => m.LLMChannelId == 40).ToListAsync();
+ Assert.Single(rows.Where(r => r.ModelName == "new-model"));
+ Assert.Single(rows.Where(r => r.ModelName == "existing-manual"));
+ Assert.False(rows.Single(r => r.ModelName == "restored-discovered").IsDeleted);
+ Assert.False(rows.Single(r => r.ModelName == "catalog-dropped").IsDeleted);
+ }
+
+ [Fact]
+ public async Task PromoteBinding_SwitchesDefaultAndMirrorsChannel() {
+ // Arrange
+ var defaultBinding = await SeedChannelWithDefaultBinding(41, "Multi Prot", LLMProvider.OpenAI, gateway: "http://a", endpoint: "http://a");
+ var responsesBinding = new LLMApiBinding {
+ LLMChannelId = 41,
+ Endpoint = "http://b",
+ Protocol = LlmProtocol.OpenAIResponses,
+ AuthProfile = LlmAuthProfile.Bearer,
+ IsDefault = false
+ };
+ await _context.LLMApiBindings.AddAsync(responsesBinding);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var ok = await _helper.PromoteBinding(41, responsesBinding.Id);
+
+ // Assert
+ Assert.True(ok);
+ Assert.True((await _context.LLMApiBindings.FindAsync(responsesBinding.Id))!.IsDefault);
+ Assert.False((await _context.LLMApiBindings.FindAsync(defaultBinding.Id))!.IsDefault);
+ var channel = await _context.LLMChannels.FindAsync(41);
+ Assert.Equal("http://b", channel.Gateway);
+ Assert.Equal(LLMProvider.ResponsesAPI, channel.Provider);
+ }
+
+ [Fact]
+ public async Task GetModelRowsByChannelId_QualifiesDuplicateNamesAndRemoveModelRowKeepsTheOther() {
+ // Arrange
+ var bindingA = await SeedChannelWithDefaultBinding(42, "Multi Model", LLMProvider.OpenAI, gateway: "http://a", endpoint: "http://a");
+ var bindingB = new LLMApiBinding { LLMChannelId = 42, Endpoint = "http://b", Protocol = LlmProtocol.OpenAIResponses, AuthProfile = LlmAuthProfile.Bearer, IsDefault = false };
+ await _context.LLMApiBindings.AddAsync(bindingB);
+ await _context.SaveChangesAsync();
+
+ var rowA = new ChannelWithModel { LLMChannelId = 42, ModelName = "shared-model", ApiBindingId = bindingA.Id };
+ var rowB = new ChannelWithModel { LLMChannelId = 42, ModelName = "shared-model", ApiBindingId = bindingB.Id };
+ var rowC = new ChannelWithModel { LLMChannelId = 42, ModelName = "single-model", ApiBindingId = bindingA.Id };
+ await _context.ChannelsWithModel.AddRangeAsync(rowA, rowB, rowC);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var rows = await _helper.GetModelRowsByChannelId(42);
+
+ // Assert: 重名行带协议标注,唯一行保持原名
+ Assert.Equal(3, rows.Count);
+ Assert.Equal(2, rows.Count(r => r.Display.Contains("shared-model [")));
+ Assert.Contains(rows, r => r.Display == "single-model");
+
+ // 按行 Id 删除只影响目标行
+ var rowToRemove = rows.First(r => r.RowId == rowB.Id);
+ Assert.True(await _helper.RemoveModelRow(rowToRemove.RowId));
+ var remaining = await _context.ChannelsWithModel.Where(m => m.LLMChannelId == 42).ToListAsync();
+ Assert.Equal(2, remaining.Count);
+ Assert.DoesNotContain(remaining, r => r.Id == rowB.Id);
+ Assert.Contains(remaining, r => r.Id == rowA.Id);
+ }
+
+ [Fact]
+ public async Task GetBindings_ReturnsChannelBindingsOrderedById() {
+ await SeedChannelWithDefaultBinding(43, "Bindings", LLMProvider.OpenAI, gateway: "http://a", endpoint: "http://a");
+ await _context.LLMApiBindings.AddAsync(new LLMApiBinding { LLMChannelId = 43, Endpoint = "http://b", Protocol = LlmProtocol.OpenAIResponses, AuthProfile = LlmAuthProfile.Bearer });
+ await _context.SaveChangesAsync();
+
+ var bindings = await _helper.GetBindings(43);
+
+ Assert.Equal(2, bindings.Count);
+ Assert.True(bindings[0].Id < bindings[1].Id);
+ }
+
[Fact]
public async Task UpdateChannel_GatewayAndProviderChange_SyncsDefaultBinding() {
// Arrange
diff --git a/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs b/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs
index e02de206..f6c9efc2 100644
--- a/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs
+++ b/TelegramSearchBot.Test/Manage/EditLLMConfTest.cs
@@ -255,61 +255,43 @@ public async Task ExecuteAsync_RemoveModel_CompleteFlow() {
ApiKey = "test-key",
Provider = LLMProvider.OpenAI
};
- await _context.LLMChannels.AddAsync(channel);
- await _context.ChannelsWithModel.AddAsync(new ChannelWithModel {
- LLMChannelId = 1,
- ModelName = "model1"
- });
- // Add model2 to the in-memory database
- await _context.ChannelsWithModel.AddAsync(new ChannelWithModel {
- LLMChannelId = 1,
- ModelName = "model2"
- });
- await _context.SaveChangesAsync();
var stateKey = $"llmconf:{chatId}:state";
var dataKey = $"llmconf:{chatId}:data";
- // Setup state transitions
+ // 1. initial state, 2. state after "移除模型", 3. state after channel input, 4. row id payload
_dbMock.SetupSequence(d => d.StringGetAsync(It.IsAny(), It.IsAny()))
- .ReturnsAsync(RedisValue.Null) // 1. Initial state before "移除模型"
- .ReturnsAsync("removing_model_select_channel") // 2. State after "移除模型" command
- .ReturnsAsync("removing_model_select") // 3. State after channel ID input
- .ReturnsAsync("1|model1,model2"); // 4. Data read by HandleRemovingModelSelectAsync
-
- // Setup helper mock to return channel
- helperMock.Setup(h => h.GetChannelById(1))
- .ReturnsAsync(channel);
-
- // Setup helper mock to return a channel in GetAllChannels, which should trigger GetModelsByChannelId in service (but service doesn't call helper here)
- // This setup is still needed for the initial "移除模型" command which lists channels.
- helperMock.Setup(h => h.GetAllChannels())
- .ReturnsAsync(new List { channel });
+ .ReturnsAsync(RedisValue.Null)
+ .ReturnsAsync("removing_model_select_channel")
+ .ReturnsAsync("removing_model_select")
+ .ReturnsAsync("1|101,102");
+
+ helperMock.Setup(h => h.GetChannelById(1)).ReturnsAsync(channel);
+ helperMock.Setup(h => h.GetAllChannels()).ReturnsAsync(new List { channel });
+ helperMock.Setup(h => h.GetModelRowsByChannelId(1)).ReturnsAsync(new List<(long, string, string)> {
+ (101, "model1", "model1"),
+ (102, "model2", "model2")
+ });
+ helperMock.Setup(h => h.RemoveModelRow(It.IsAny())).ReturnsAsync(true);
// Act & Assert
var result1 = await _service.ExecuteAsync("移除模型", chatId);
Assert.True(result1.Item1);
Assert.Contains("请选择要移除模型的渠道ID:", result1.Item2);
- // Verify the in-memory database contains the expected models
- var modelsInDb = await _context.ChannelsWithModel.Where(m => m.LLMChannelId == 1).ToListAsync();
- Assert.NotNull(modelsInDb);
- Assert.Equal(2, modelsInDb.Count);
- Assert.Contains(modelsInDb, m => m.ModelName == "model1");
- Assert.Contains(modelsInDb, m => m.ModelName == "model2");
-
- var result2 = await _service.ExecuteAsync("1", chatId); // User inputs channel ID
+ var result2 = await _service.ExecuteAsync("1", chatId);
Assert.True(result2.Item1);
- // Verify the response lists models
Assert.Contains("请选择要移除的模型:", result2.Item2);
Assert.Contains("1. model1", result2.Item2);
+ Assert.Contains("2. model2", result2.Item2);
- var result3 = await _service.ExecuteAsync("1", chatId); // User inputs model index (1 for model1)
+ var result3 = await _service.ExecuteAsync("1", chatId); // 选择第一行
Assert.True(result3.Item1);
Assert.Equal("模型移除成功", result3.Item2);
- // Verify model was removed by helper (as service calls helper)
- helperMock.Verify(h => h.RemoveModelFromChannel(1, "model1"), Times.Once);
+ // 按行 Id 删除(不是按模型名,多 binding 下不会删错行)
+ helperMock.Verify(h => h.RemoveModelRow(101), Times.Once);
+ helperMock.Verify(h => h.RemoveModelRow(It.IsAny()), Times.Once);
// Verify Redis keys are deleted
_dbMock.Verify(d => d.KeyDeleteAsync(stateKey, It.IsAny()), Times.Once);
@@ -553,5 +535,83 @@ public async Task ExecuteAsync_PresetOpenCodeGo_CreatesResponseBindingAndAssigns
helperMock.Verify(h => h.AssignModelBinding(7, "glm-5.3", It.IsAny()), Times.Never);
helperMock.Verify(h => h.AssignModelBinding(7, It.IsAny(), It.IsAny()), Times.Exactly(2));
}
+
+ [Fact]
+ public async Task ExecuteAsync_EditChannelBindings_PromotesSelectedBinding() {
+ // Arrange
+ long chatId = 123;
+ var channel = new LLMChannel { Id = 1, Name = "Test Channel", Provider = LLMProvider.OpenAI, Gateway = "http://test.com" };
+ var binding = new LLMApiBinding { Id = 5, LLMChannelId = 1, Endpoint = "http://test.com/v1", Protocol = LlmProtocol.OpenAIResponses, AuthProfile = LlmAuthProfile.Bearer };
+
+ _dbMock.SetupSequence(d => d.StringGetAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(RedisValue.Null)
+ .ReturnsAsync("editing_select_channel")
+ .ReturnsAsync("editing_select_field")
+ .ReturnsAsync("1")
+ .ReturnsAsync("editing_select_binding")
+ .ReturnsAsync("1|7");
+
+ helperMock.Setup(h => h.GetAllChannels()).ReturnsAsync(new List { channel });
+ helperMock.Setup(h => h.GetChannelById(1)).ReturnsAsync(channel);
+ helperMock.Setup(h => h.GetBindings(1)).ReturnsAsync(new List { binding });
+ helperMock.Setup(h => h.PromoteBinding(1, 5)).ReturnsAsync(true);
+
+ // Act
+ var result1 = await _service.ExecuteAsync("编辑渠道", chatId);
+ Assert.True(result1.Item1);
+
+ var result2 = await _service.ExecuteAsync("1", chatId);
+ Assert.Contains("7. 协议绑定", result2.Item2);
+
+ var result3 = await _service.ExecuteAsync("7", chatId);
+ Assert.True(result3.Item1);
+ Assert.Contains("5. http://test.com/v1 (OpenAIResponses/Bearer)", result3.Item2);
+ Assert.Contains("0 新增绑定", result3.Item2);
+
+ var result4 = await _service.ExecuteAsync("5", chatId);
+
+ // Assert
+ Assert.True(result4.Item1);
+ Assert.Contains("设为默认绑定", result4.Item2);
+ helperMock.Verify(h => h.PromoteBinding(1, 5), Times.Once);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_EditChannelBindings_AddsNewBinding() {
+ // Arrange
+ long chatId = 123;
+ var existing = new LLMApiBinding { Id = 3, LLMChannelId = 1, Endpoint = "https://opencode.ai/zen/v1", Protocol = LlmProtocol.OpenAIChat, AuthProfile = LlmAuthProfile.Bearer, IsDefault = true };
+
+ _dbMock.SetupSequence(d => d.StringGetAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync("editing_select_field").ReturnsAsync("1")
+ .ReturnsAsync("editing_select_binding").ReturnsAsync("1|7")
+ .ReturnsAsync("editing_binding_endpoint").ReturnsAsync("1|new")
+ .ReturnsAsync("editing_binding_protocol").ReturnsAsync("1|https://opencode.ai/zen/v1")
+ .ReturnsAsync("editing_binding_auth").ReturnsAsync("1|https://opencode.ai/zen/v1|OpenAIResponses");
+
+ helperMock.Setup(h => h.GetBindings(1)).ReturnsAsync(new List { existing });
+ helperMock.Setup(h => h.EnsureBinding(1, "https://opencode.ai/zen/v1", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer)).ReturnsAsync(55);
+
+ // Act
+ var openBindings = await _service.ExecuteAsync("7", chatId);
+ Assert.Contains("3. https://opencode.ai/zen/v1", openBindings.Item2);
+
+ var askEndpoint = await _service.ExecuteAsync("0", chatId);
+ Assert.Contains("请输入新绑定的端点地址", askEndpoint.Item2);
+
+ var askProtocol = await _service.ExecuteAsync("https://opencode.ai/zen/v1", chatId);
+ Assert.Contains("请选择线协议", askProtocol.Item2);
+ Assert.Contains("2. OpenAIResponses", askProtocol.Item2);
+
+ var askAuth = await _service.ExecuteAsync("2", chatId);
+ Assert.Contains("请选择认证方式", askAuth.Item2);
+
+ var created = await _service.ExecuteAsync("1", chatId);
+
+ // Assert
+ Assert.True(created.Item1);
+ Assert.Contains("绑定创建成功(ID: 55", created.Item2);
+ helperMock.Verify(h => h.EnsureBinding(1, "https://opencode.ai/zen/v1", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer), Times.Once);
+ }
}
}
diff --git a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs
index 01f6644d..b9c18c25 100644
--- a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs
+++ b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs
@@ -262,6 +262,14 @@ public static async Task Startup(string[] args) {
Log.Warning(ex, "Failed to export tool definitions to Redis. Agent processes may have limited tools.");
}
+ // 预加载并记录预设目录来源/版本(providers.json 覆盖或内置)
+ try {
+ Log.Information("LLM 预设目录已加载: {Source} (generatedAt={GeneratedAt}, presets={Count})",
+ LlmProviderCatalog.Source, LlmProviderCatalog.GeneratedAt, LlmProviderCatalog.Presets.Count);
+ } catch (Exception ex) {
+ Log.Error(ex, "LLM 预设目录加载失败,预制渠道将不可用");
+ }
+
// 启动Host,SchedulerService作为HostedService会自动启动
await host.StartAsync();
Log.Information("Host已启动,定时任务调度器已作为后台服务启动");
diff --git a/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs b/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs
index 528e5545..d3740f14 100644
--- a/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs
+++ b/TelegramSearchBot/Interface/Manage/IEditLLMConfHelper.cs
@@ -37,5 +37,20 @@ public interface IEditLLMConfHelper {
/// 把模型行改挂到指定 binding,并标记为模型级协议覆盖;成功返回 true。
Task AssignModelBinding(int channelId, string modelName, int bindingId);
+
+ /// 渠道下的全部协议绑定(按 Id 升序)。
+ Task> GetBindings(int channelId);
+
+ /// 把指定 binding 设为渠道默认(其余降级),并镜像 channel.Gateway/Provider;成功返回 true。
+ Task PromoteBinding(int channelId, int bindingId);
+
+ ///
+ /// 渠道下的模型行(按行 Id),展示格式与 GetModelsByChannelId 一致(多 binding 时带 [channel/binding/protocol]);
+ /// 移除模型按行 Id 删除,避免多 binding 下删错行。
+ ///
+ Task> GetModelRowsByChannelId(long channelId);
+
+ /// 按行 Id 删除模型行;行不存在返回 false。
+ Task RemoveModelRow(long rowId);
}
}
diff --git a/TelegramSearchBot/Model/AI/LLMConfState.cs b/TelegramSearchBot/Model/AI/LLMConfState.cs
index 1734008a..723d9954 100644
--- a/TelegramSearchBot/Model/AI/LLMConfState.cs
+++ b/TelegramSearchBot/Model/AI/LLMConfState.cs
@@ -32,6 +32,18 @@ public enum LLMConfState {
[Description("editing_select_channel")]
EditingSelectChannel,
+ [Description("editing_select_binding")]
+ EditingSelectBinding,
+
+ [Description("editing_binding_endpoint")]
+ EditingBindingEndpoint,
+
+ [Description("editing_binding_protocol")]
+ EditingBindingProtocol,
+
+ [Description("editing_binding_auth")]
+ EditingBindingAuth,
+
[Description("adding_model_select_channel")]
AddingModelSelectChannel,
diff --git a/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs b/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs
index 9604212e..d50a5b8e 100644
--- a/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs
+++ b/TelegramSearchBot/Service/Manage/EditLLMConfHelper.cs
@@ -761,6 +761,82 @@ public async Task AssignModelBinding(int channelId, string modelName, int
}
}
+ /// 渠道下的全部协议绑定(按 Id 升序)。
+ public async Task> GetBindings(int channelId) {
+ try {
+ return await DataContext.LLMApiBindings
+ .Where(b => b.LLMChannelId == channelId)
+ .OrderBy(b => b.Id)
+ .ToListAsync();
+ } catch {
+ return new List();
+ }
+ }
+
+ /// 把指定 binding 设为渠道默认(其余降级),并镜像 channel.Gateway/Provider(blueprint §七)。
+ public async Task PromoteBinding(int channelId, int bindingId) {
+ try {
+ var channel = await DataContext.LLMChannels.Include(c => c.Bindings).FirstOrDefaultAsync(c => c.Id == channelId);
+ if (channel == null) {
+ return false;
+ }
+
+ var target = channel.Bindings.FirstOrDefault(b => b.Id == bindingId);
+ if (target == null) {
+ _logger.LogWarning("设置默认 binding 失败:渠道 {ChannelId} 不存在 binding {BindingId}", channelId, bindingId);
+ return false;
+ }
+
+ foreach (var binding in channel.Bindings) {
+ binding.IsDefault = binding.Id == target.Id;
+ }
+ channel.Gateway = target.Endpoint;
+ channel.Provider = MapProtocolToProvider(target.Protocol);
+ await DataContext.SaveChangesAsync();
+ return true;
+ } catch (Exception ex) {
+ _logger.LogError(ex, "把 binding {BindingId} 设为渠道 {ChannelId} 默认失败", bindingId, channelId);
+ return false;
+ }
+ }
+
+ /// 渠道下的模型行(按行 Id),展示格式与 GetModelsByChannelId 一致(多 binding 带协议标注)。
+ public async Task> GetModelRowsByChannelId(long channelId) {
+ var rows = await DataContext.ChannelsWithModel
+ .Include(c => c.ApiBinding)
+ .Include(c => c.LLMChannel)
+ .Where(c => c.LLMChannelId == channelId && !c.IsDeleted)
+ .OrderBy(c => c.ModelName).ThenBy(c => c.Id)
+ .ToListAsync();
+
+ var multiBindingNames = rows
+ .GroupBy(r => r.ModelName, StringComparer.OrdinalIgnoreCase)
+ .Where(g => g.Count() > 1)
+ .Select(g => g.Key)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ return rows
+ .Select(r => ((long)r.Id, r.ModelName, multiBindingNames.Contains(r.ModelName) ? FormatModelDisplay(r) : r.ModelName))
+ .ToList();
+ }
+
+ /// 按行 Id 删除模型行(多 binding 下不会误删同名的其它行)。
+ public async Task RemoveModelRow(long rowId) {
+ try {
+ var row = await DataContext.ChannelsWithModel.FirstOrDefaultAsync(m => m.Id == rowId);
+ if (row == null) {
+ return false;
+ }
+
+ DataContext.ChannelsWithModel.Remove(row);
+ await DataContext.SaveChangesAsync();
+ return true;
+ } catch (Exception ex) {
+ _logger.LogError(ex, "删除模型行 {RowId} 失败", rowId);
+ return false;
+ }
+ }
+
///
/// channel Gateway/Provider 变更时同步默认 binding(镜像规则,blueprint §七)。
/// 无默认 binding 时不创建(补建由 EnsureDefaultBinding 在 AddChannel/RefreshAllChannel 负责)。
diff --git a/TelegramSearchBot/Service/Manage/EditLLMConfService.cs b/TelegramSearchBot/Service/Manage/EditLLMConfService.cs
index 2a268d48..41d341bd 100644
--- a/TelegramSearchBot/Service/Manage/EditLLMConfService.cs
+++ b/TelegramSearchBot/Service/Manage/EditLLMConfService.cs
@@ -61,6 +61,10 @@ MusicGenerationToolSettingsService musicGenerationToolSettingsService
{ LLMConfState.SettingAltPhotoModel.GetDescription(), HandleSettingAltPhotoModelAsync },
{ LLMConfState.EditingSelectChannel.GetDescription(), HandleEditingSelectChannelAsync },
{ LLMConfState.EditingSelectField.GetDescription(), HandleEditingSelectFieldAsync },
+ { LLMConfState.EditingSelectBinding.GetDescription(), HandleEditingSelectBindingAsync },
+ { LLMConfState.EditingBindingEndpoint.GetDescription(), HandleEditingBindingEndpointAsync },
+ { LLMConfState.EditingBindingProtocol.GetDescription(), HandleEditingBindingProtocolAsync },
+ { LLMConfState.EditingBindingAuth.GetDescription(), HandleEditingBindingAuthAsync },
{ LLMConfState.AddingModelSelectChannel.GetDescription(), HandleAddingModelSelectChannelAsync },
{ LLMConfState.AddingModelInput.GetDescription(), HandleAddingModelInputAsync },
{ LLMConfState.RemovingModelSelectChannel.GetDescription(), HandleRemovingModelSelectChannelAsync },
@@ -341,7 +345,7 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration
await redis.SetDataAsync(channelId.ToString());
await redis.SetStateAsync(LLMConfState.EditingSelectField.GetDescription());
- return (true, $"请选择要编辑的字段:\n1. 名称 ({channel.Name})\n2. 地址 ({channel.Gateway})\n3. 类型 ({channel.Provider})\n4. API Key\n5. 最大并行数量 ({channel.Parallel})\n6. 优先级 ({channel.Priority})");
+ return (true, $"请选择要编辑的字段:\n1. 名称 ({channel.Name})\n2. 地址 ({channel.Gateway})\n3. 类型 ({channel.Provider})\n4. API Key\n5. 最大并行数量 ({channel.Parallel})\n6. 优先级 ({channel.Priority})\n7. 协议绑定");
}
private async Task<(bool, string)> HandleEditingSelectFieldAsync(EditLLMConfRedisHelper redis, string command) {
@@ -349,6 +353,10 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration
var editChannelId = int.Parse(value);
await redis.SetDataAsync($"{editChannelId}|{command}");
+ if (command == "7") {
+ return await ShowBindingSelectionAsync(redis, editChannelId);
+ }
+
if (command == "3") {
await redis.SetStateAsync(LLMConfState.EditingInputValue.GetDescription());
@@ -369,6 +377,135 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration
}
}
+ /// 列出渠道的协议绑定并询问操作(输入编号设为默认,0 新增)。
+ private async Task<(bool, string)> ShowBindingSelectionAsync(EditLLMConfRedisHelper redis, int channelId) {
+ var bindings = await Helper.GetBindings(channelId);
+ if (bindings.Count == 0) {
+ await redis.DeleteKeysAsync();
+ return (true, "该渠道没有任何协议绑定");
+ }
+
+ var sb = new StringBuilder($"渠道 {channelId} 的协议绑定:\n");
+ foreach (var binding in bindings) {
+ var marker = binding.IsDefault ? " [默认]" : string.Empty;
+ sb.AppendLine($"{binding.Id}. {binding.Endpoint} ({binding.Protocol}/{binding.AuthProfile}){marker}");
+ }
+ sb.Append("请输入要设为默认的绑定ID,或发送 0 新增绑定:");
+
+ await redis.SetStateAsync(LLMConfState.EditingSelectBinding.GetDescription());
+ return (true, sb.ToString());
+ }
+
+ private async Task<(bool, string)> HandleEditingSelectBindingAsync(EditLLMConfRedisHelper redis, string command) {
+ var data = await redis.GetDataAsync();
+ var parts = data.Split('|');
+ if (parts.Length < 2 || !int.TryParse(parts[0], out var channelId)) {
+ await redis.DeleteKeysAsync();
+ return (true, "内部错误:渠道状态丢失");
+ }
+
+ var bindings = await Helper.GetBindings(channelId);
+ var cmd = command.Trim();
+
+ if (cmd == "0" || cmd.Equals("新增", StringComparison.OrdinalIgnoreCase)) {
+ await redis.SetDataAsync($"{channelId}|new");
+ await redis.SetStateAsync(LLMConfState.EditingBindingEndpoint.GetDescription());
+ return (true, "请输入新绑定的端点地址(例如 https://opencode.ai/zen/v1):");
+ }
+
+ if (!int.TryParse(cmd, out var bindingId) || bindings.All(b => b.Id != bindingId)) {
+ return (false, "请输入列表中的绑定ID,或发送 0 新增绑定");
+ }
+
+ if (!await Helper.PromoteBinding(channelId, bindingId)) {
+ await redis.DeleteKeysAsync();
+ return (true, "设置为默认绑定失败");
+ }
+
+ await redis.DeleteKeysAsync();
+ return (true, $"已将 binding {bindingId} 设为默认绑定(渠道地址与类型已同步)。");
+ }
+
+ private async Task<(bool, string)> HandleEditingBindingEndpointAsync(EditLLMConfRedisHelper redis, string command) {
+ var data = await redis.GetDataAsync();
+ var parts = data.Split('|');
+ if (parts.Length < 2 || !int.TryParse(parts[0], out var channelId)) {
+ await redis.DeleteKeysAsync();
+ return (true, "内部错误:渠道状态丢失");
+ }
+
+ var endpoint = command.Trim();
+ if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) ||
+ ( uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps )) {
+ return (false, "请输入有效的端点地址(以 http:// 或 https:// 开头)");
+ }
+
+ await redis.SetDataAsync($"{channelId}|{endpoint}");
+ await redis.SetStateAsync(LLMConfState.EditingBindingProtocol.GetDescription());
+ return (true, FormatProtocolOptions());
+ }
+
+ private async Task<(bool, string)> HandleEditingBindingProtocolAsync(EditLLMConfRedisHelper redis, string command) {
+ var data = await redis.GetDataAsync();
+ var parts = data.Split('|');
+ if (parts.Length < 2 || !int.TryParse(parts[0], out var channelId)) {
+ await redis.DeleteKeysAsync();
+ return (true, "内部错误:渠道状态丢失");
+ }
+
+ var protocols = Enum.GetValues();
+ if (!int.TryParse(command.Trim(), out var index) || index < 1 || index > protocols.Length) {
+ return (false, $"请输入 1 到 {protocols.Length} 之间的数字");
+ }
+
+ await redis.SetDataAsync($"{channelId}|{parts[1]}|{protocols[index - 1]}");
+ await redis.SetStateAsync(LLMConfState.EditingBindingAuth.GetDescription());
+ return (true, FormatAuthOptions());
+ }
+
+ private async Task<(bool, string)> HandleEditingBindingAuthAsync(EditLLMConfRedisHelper redis, string command) {
+ var data = await redis.GetDataAsync();
+ var parts = data.Split('|', 3);
+ if (parts.Length < 3 || !int.TryParse(parts[0], out var channelId)) {
+ await redis.DeleteKeysAsync();
+ return (true, "内部错误:绑定状态丢失");
+ }
+
+ var authProfiles = Enum.GetValues();
+ if (!int.TryParse(command.Trim(), out var index) || index < 1 || index > authProfiles.Length) {
+ return (false, $"请输入 1 到 {authProfiles.Length} 之间的数字");
+ }
+
+ if (!Enum.TryParse(parts[2], out var protocol)) {
+ await redis.DeleteKeysAsync();
+ return (true, "内部错误:协议解析失败");
+ }
+
+ var bindingId = await Helper.EnsureBinding(channelId, parts[1], protocol, authProfiles[index - 1]);
+ await redis.DeleteKeysAsync();
+ return bindingId > 0
+ ? (true, $"绑定创建成功(ID: {bindingId},{protocol}/{authProfiles[index - 1]})。可用 `编辑渠道` 第 7 项把它设为默认。")
+ : (true, "绑定创建失败");
+ }
+
+ private static string FormatProtocolOptions() {
+ var protocols = Enum.GetValues();
+ var sb = new StringBuilder("请选择线协议:\n");
+ for (var i = 0; i < protocols.Length; i++) {
+ sb.AppendLine($"{i + 1}. {protocols[i]}");
+ }
+ return sb.ToString().TrimEnd();
+ }
+
+ private static string FormatAuthOptions() {
+ var authProfiles = Enum.GetValues();
+ var sb = new StringBuilder("请选择认证方式:\n");
+ for (var i = 0; i < authProfiles.Length; i++) {
+ sb.AppendLine($"{i + 1}. {authProfiles[i]}");
+ }
+ return sb.ToString().TrimEnd();
+ }
+
private async Task<(bool, string)> HandleAddingModelSelectChannelAsync(EditLLMConfRedisHelper redis, string command) {
if (!int.TryParse(command, out var addModelChannelId)) {
return (false, "请输入有效的渠道ID");
@@ -404,23 +541,20 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration
return (true, "找不到指定的渠道");
}
- // 获取该渠道下的所有模型
- var models = await DataContext.ChannelsWithModel
- .Where(m => m.LLMChannelId == removeModelChannelId && !m.IsDeleted)
- .Select(m => m.ModelName)
- .ToListAsync();
+ // 获取该渠道下的所有模型行(多 binding 时带协议标注;按行 Id 删除避免误删同名行)
+ var modelRows = await Helper.GetModelRowsByChannelId(removeModelChannelId);
- if (models.Count == 0) {
+ if (modelRows.Count == 0) {
return (true, "该渠道下没有可移除的模型");
}
var sb = new StringBuilder();
sb.AppendLine("请选择要移除的模型:");
- for (int i = 0; i < models.Count; i++) {
- sb.AppendLine($"{i + 1}. {models[i]}");
+ for (int i = 0; i < modelRows.Count; i++) {
+ sb.AppendLine($"{i + 1}. {modelRows[i].Display}");
}
- await redis.SetDataAsync($"{removeModelChannelId}|{string.Join(",", models)}");
+ await redis.SetDataAsync($"{removeModelChannelId}|{string.Join(",", modelRows.Select(r => r.RowId))}");
await redis.SetStateAsync(LLMConfState.RemovingModelSelect.GetDescription());
return (true, sb.ToString());
}
@@ -433,23 +567,21 @@ await DataContext.AppConfigurationItems.AddAsync(new Model.Data.AppConfiguration
return (false, "内部错误:模型数据格式不正确");
}
- var removeChannelId = int.Parse(parts[0]);
- var modelList = parts[1].Split(',');
+ var rowIds = parts[1].Split(',', StringSplitOptions.RemoveEmptyEntries);
- if (modelList.Length == 0 || ( modelList.Length == 1 && string.IsNullOrEmpty(modelList[0]) )) {
+ if (rowIds.Length == 0) {
return (true, "该渠道下没有可移除的模型");
}
- if (!int.TryParse(command, out var modelIndex) || modelIndex < 1 || modelIndex > modelList.Length) {
+ if (!int.TryParse(command, out var modelIndex) || modelIndex < 1 || modelIndex > rowIds.Length) {
return (true, "请输入有效的模型序号");
}
- if (modelIndex - 1 < 0 || modelIndex - 1 >= modelList.Length) {
- return (true, "内部错误:无效的模型序号");
+ if (!long.TryParse(rowIds[modelIndex - 1], out var rowId)) {
+ return (true, "内部错误:模型行ID无效");
}
- var modelName = modelList[modelIndex - 1];
- var removeResult = await Helper.RemoveModelFromChannel(removeChannelId, modelName);
+ var removeResult = await Helper.RemoveModelRow(rowId);
// 清理状态
await redis.DeleteKeysAsync();