Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions Docs/Bot_Commands_User_Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 机器人回复: `请输入渠道的名称`
Expand All @@ -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`**:
- 机器人回复: `请输入新的值:`
Expand All @@ -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`)
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
125 changes: 125 additions & 0 deletions TelegramSearchBot.LLM/Providers/providers.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
110 changes: 15 additions & 95 deletions TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,20 @@
using TelegramSearchBot.Model.AI;

namespace TelegramSearchBot.Service.AI.LLM {
/// <summary>A built-in provider preset: one code-defined entry of the provider catalog.</summary>
/// <summary>A built-in provider preset: one entry of the provider catalog.</summary>
public sealed record LlmProviderPreset(
string Id,
string DisplayName,
LLMProvider Provider,
/// <summary>Null = gateway must be entered during creation (e.g. user-owned OpenCode Go gateway).</summary>
string? DefaultGateway,

Check warning on line 13 in TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
string[] DefaultModels,
bool RequiresApiKey,
string? Notes = null,

Check warning on line 16 in TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
/// <summary>Extra protocol bindings created alongside the channel default (multi-protocol gateways).</summary>
IReadOnlyList<LlmPresetBinding>? Bindings = null,

Check warning on line 18 in TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
/// <summary>Model-name prefix → binding id; unmatched models stay on the channel default binding.</summary>
IReadOnlyList<LlmModelBindingRule>? ModelBindingRules = null,

Check warning on line 20 in TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
/// <summary>
/// True when the gateway catalog IS the entitlement (subscription, e.g. OpenCode Go): refresh may add
/// discovered models. False for pay-per-token catalogs (e.g. OpenCode Zen) where listing ≠ authorization.
Expand Down Expand Up @@ -53,102 +53,22 @@
}

/// <summary>
/// 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.
/// </summary>
public static class LlmProviderCatalog {
public static readonly IReadOnlyList<LlmProviderPreset> 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<string>(),
RequiresApiKey: false,
Notes: "本地服务无需 API Key;模型通过 `添加模型` 手动添加或自动发现。"),
new LlmProviderPreset(
"lmstudio", "本地 LM Studio", LLMProvider.LMStudio,
"http://localhost:1234/v1",
Array.Empty<string>(),
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<string>(),
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<LlmProviderCatalogDocument> Document =
new(() => LlmProviderCatalogLoader.Load(), LazyThreadSafetyMode.ExecutionAndPublication);

public static IReadOnlyList<LlmProviderPreset> Presets => Document.Value.Presets;

/// <summary>内置/覆盖目录的数据版本(providers.json 的 generatedAt)。</summary>
public static DateTimeOffset? GeneratedAt => Document.Value.GeneratedAt;

/// <summary>目录来源(`builtin` 或 `file:<path>`),便于排障。</summary>
public static string Source => Document.Value.Source;

public static LlmProviderPreset? FindById(string id) =>
Presets.FirstOrDefault(p => p.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
Expand Down
Loading
Loading