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
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Linq;
using System.Net.Http;
using TelegramSearchBot.Service.AI.LLM.Transports;
using Xunit;

namespace TelegramSearchBot.LLM.Test.Service.AI.LLM {
public class AnthropicTransportSessionTests {
[Fact]
public void CreateSessionHttpClient_AppliesStableSessionForOpenCodeEndpoints() {
using var client = AnthropicMessagesTransport.CreateSessionHttpClient("https://opencode.ai/zen/go/v1", 42);

Assert.Equal("tsb-42", client.DefaultRequestHeaders.GetValues("x-opencode-session").Single());
}

[Fact]
public void CreateSessionHttpClient_UsesGlobalSessionWithoutChatId() {
using var client = AnthropicMessagesTransport.CreateSessionHttpClient("https://opencode.ai/zen/v1");

Assert.Equal("tsb-global", client.DefaultRequestHeaders.GetValues("x-opencode-session").Single());
}

[Fact]
public void CreateSessionHttpClient_LeavesOtherEndpointsUntouched() {
using var client = AnthropicMessagesTransport.CreateSessionHttpClient("https://api.anthropic.com/v1", 42);

Assert.False(client.DefaultRequestHeaders.Contains("x-opencode-session"));
}
}
}
100 changes: 100 additions & 0 deletions TelegramSearchBot.LLM.Test/Service/AI/LLM/LlmProviderCatalogTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using System;
using System.Linq;
using TelegramSearchBot.Model.AI;
using TelegramSearchBot.Service.AI.LLM;
using Xunit;

namespace TelegramSearchBot.LLM.Test.Service.AI.LLM {
public class LlmProviderCatalogTests {
[Theory]
[InlineData("claude-sonnet-5", "anthropic")]
[InlineData("Claude-Opus-5", "anthropic")]
[InlineData("gpt-5.6-luna", "responses")]
[InlineData("grok-4.6", "responses")]
[InlineData("gemini-3.8-flash", "google")]
public void OpenCodeZen_RulesMapModelsToProtocols(string modelName, string expectedBindingId) {
var zen = LlmProviderCatalog.FindById("opencode-zen")!;

Assert.Equal(expectedBindingId, zen.ModelBindingRules.ResolveBindingId(modelName));
}

[Theory]
[InlineData("glm-5.3", null)]
[InlineData("kimi-k3", null)]
[InlineData("deepseek-v4-pro", null)]
public void OpenCodeZen_UnmatchedModelsKeepDefaultBinding(string modelName, string? expectedBindingId) {
var zen = LlmProviderCatalog.FindById("opencode-zen")!;

Assert.Equal(expectedBindingId, zen.ModelBindingRules.ResolveBindingId(modelName));
}

[Theory]
[InlineData("grok-4.6", "responses")]
[InlineData("gpt-5.6-luna", "responses")]
[InlineData("glm-5.3", null)]
public void OpenCodeGo_RulesMapResponsesModelsOnly(string modelName, string? expectedBindingId) {
var go = LlmProviderCatalog.FindById("opencode-go")!;

Assert.Equal(expectedBindingId, go.ModelBindingRules.ResolveBindingId(modelName));
}

[Fact]
public void OpenCodeGo_SeedsOfficialModels_WithResponsesBindingForGrokAndGpt() {
var go = LlmProviderCatalog.FindById("opencode-go")!;

Assert.Contains("grok-4.6", go.DefaultModels);
Assert.Contains("minimax-m3", go.DefaultModels);
Assert.Contains("qwen3.7-max", go.DefaultModels);
Assert.Equal("responses", go.ModelBindingRules.ResolveBindingId("grok-4.6"));
Assert.Equal("responses", go.ModelBindingRules.ResolveBindingId("gpt-5.6-luna"));
Assert.Null(go.ModelBindingRules.ResolveBindingId("glm-5.3"));
Assert.True(go.CatalogIsEntitlement);
}

[Fact]
public void OpenCodeZen_IsNotEntitlement_AndSeedsNoModels() {
var zen = LlmProviderCatalog.FindById("opencode-zen")!;

Assert.False(zen.CatalogIsEntitlement);
Assert.Empty(zen.DefaultModels);
Assert.Equal("https://opencode.ai/zen/v1", zen.DefaultGateway);
Assert.Equal(3, zen.Bindings!.Count);
}

[Theory]
[InlineData("https://opencode.ai/zen/go/v1", "opencode-go")]
[InlineData("https://opencode.ai/zen/go", "opencode-go")]
[InlineData("https://opencode.ai/zen/v1", "opencode-zen")]
[InlineData("https://opencode.ai/zen", "opencode-zen")]
[InlineData("https://api.openai.com/v1", null)]
[InlineData("not a url", null)]
public void FindForEndpoint_ClassifiesOpenCodeGateways(string endpoint, string? expectedPresetId) {
Assert.Equal(expectedPresetId, LlmProviderCatalog.FindForEndpoint(endpoint)?.Id);
}

[Theory]
[InlineData("https://opencode.ai/zen", "/v1", "https://opencode.ai/zen/v1")]
[InlineData("https://opencode.ai/zen/v1", "/v1", "https://opencode.ai/zen/v1")]
[InlineData("https://opencode.ai/zen/v1/", "/v1", "https://opencode.ai/zen/v1")]
[InlineData("https://example.com", null, "https://example.com")]
public void BuildBindingEndpoint_AppendsSuffixAtMostOnce(string gateway, string? suffix, string expected) {
Assert.Equal(expected, LlmProviderCatalog.BuildBindingEndpoint(gateway, suffix));
}

[Fact]
public void EveryPreset_HasResolvableRuleBindings() {
foreach (var preset in LlmProviderCatalog.Presets) {
if (preset.ModelBindingRules == null) {
continue;
}

var bindingIds = preset.Bindings?.Select(b => b.Id).ToHashSet(StringComparer.OrdinalIgnoreCase)
?? new System.Collections.Generic.HashSet<string>();
foreach (var rule in preset.ModelBindingRules) {
// 规则引用的 binding 必须存在于预设声明中(默认 binding 由渠道提供,不在此列)。
Assert.Contains(rule.BindingId, bindingIds);
}
}
}
}
}
115 changes: 107 additions & 8 deletions TelegramSearchBot.LLM/Service/AI/LLM/LlmProviderCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,47 @@
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);
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.
/// </summary>
bool CatalogIsEntitlement = false);

/// <summary>Extra API binding a preset creates: same gateway, different wire protocol/auth.</summary>
public sealed record LlmPresetBinding(
string Id,
LlmProtocol Protocol,
LlmAuthProfile AuthProfile,
/// <summary>Path appended to the entered gateway; not duplicated when the gateway already ends with it.</summary>
string EndpointSuffix = "/v1");

/// <summary>Model-name (case-insensitive) prefix → binding id.</summary>
public sealed record LlmModelBindingRule(string BindingId, params string[] Prefixes);

public static class LlmModelBindingRules {
/// <summary>First matching rule wins; null = keep the channel default binding.</summary>
public static string? ResolveBindingId(this IReadOnlyList<LlmModelBindingRule>? rules, string? modelName) {
if (rules == null || string.IsNullOrWhiteSpace(modelName)) {
return null;
}

foreach (var rule in rules) {
if (rule.Prefixes.Any(prefix => modelName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) {
return rule.BindingId;
}
}

return null;
}
}

/// <summary>
/// Code-defined provider catalog (pi-style): common providers ship preconfigured so the bot
Expand Down Expand Up @@ -69,23 +106,84 @@
"https://api.moonshot.cn/v1",
new[] { "kimi-k2-0711-preview", "moonshot-v1-128k" },
RequiresApiKey: true),
// OpenCode 网关不是单协议服务商:Zen 按模型分属 Anthropic/OpenAI/Google 协议,
// Go 只有 Responses + Chat Completions。预设建渠道时按 ModelBindingRules 拆 binding。
new LlmProviderPreset(
"opencode-zen", "OpenCode Zen (官方订阅目录)", LLMProvider.Anthropic,
"https://opencode.ai/zen",
"opencode-zen", "OpenCode Zen (官方订阅目录)", LLMProvider.OpenAI,
"https://opencode.ai/zen/v1",
Array.Empty<string>(),
RequiresApiKey: true,
Notes: "Anthropic 兼容;订阅 token 作为 API Key;目录模型不自动创建,授权模型请用 添加模型 手工维护。"),
Notes: "多协议网关(claude-*→Anthropic、gpt-*/grok-*→Responses、gemini-*→Google、其余→Chat Completions);按量计费,目录不自动创建,授权模型请用 `添加模型`。",
Bindings: new[] {
new LlmPresetBinding("anthropic", LlmProtocol.AnthropicMessages, LlmAuthProfile.AnthropicApiKey),
new LlmPresetBinding("responses", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer),
new LlmPresetBinding("google", LlmProtocol.Gemini, LlmAuthProfile.Bearer)
},
ModelBindingRules: new[] {
new LlmModelBindingRule("anthropic", "claude-"),
new LlmModelBindingRule("responses", "gpt-", "grok-", "o3", "o4"),
new LlmModelBindingRule("google", "gemini-")
}),
new LlmProviderPreset(
"opencode-go", "OpenCode Go (自建网关)", LLMProvider.Anthropic,
null,
Array.Empty<string>(),
"opencode-go", "OpenCode Go (订阅网关)", LLMProvider.OpenAI,
"https://opencode.ai/zen/go/v1",
new[] {
"grok-4.6", "gpt-5.6-luna",
"glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1",
"kimi-k3", "kimi-k2.7-code", "kimi-k2.6",
"longcat-2.0",
"deepseek-v4.1-flash", "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp",
"minimax-m3", "minimax-m2.7",
"mimo-v2.5", "mimo-v2.5-pro",
"qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus",
"muse-spark-1.3-contributor", "muse-spark-1.2-contributor",
"hy4-preview", "hy3"
},
RequiresApiKey: true,
Notes: "创建时需输入你的 OpenCode Go 网关地址;Anthropic 兼容,自动携带 x-opencode-session 头;模型请用 添加模型 维护。"),
Notes: "OpenAI 兼容订阅网关(/responses + /chat/completions),自动携带 x-opencode-session;订阅覆盖目录模型,刷新会同步目录。自建网关请用 `新建渠道`。",
Bindings: new[] {
new LlmPresetBinding("responses", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer)
},
ModelBindingRules: new[] {
new LlmModelBindingRule("responses", "grok-", "gpt-")
},
CatalogIsEntitlement: true),
};

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

/// <summary>
/// 按渠道端点识别 OpenCode 预设(opencode.ai/zen/go/* → Go,其余 /zen/* → Zen)。
/// 用于刷新时决定目录策略与模型→协议规则。
/// </summary>
public static LlmProviderPreset? FindForEndpoint(string? endpoint) {
if (string.IsNullOrWhiteSpace(endpoint) ||
!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) ||
!string.Equals(uri.Host, "opencode.ai", StringComparison.OrdinalIgnoreCase)) {
return null;
}

var path = uri.AbsolutePath;
if (path.StartsWith("/zen/go", StringComparison.OrdinalIgnoreCase)) {
return FindById("opencode-go");
}
if (path.Equals("/zen", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/zen/", StringComparison.OrdinalIgnoreCase)) {
return FindById("opencode-zen");
}
return null;
}

/// <summary>把用户输入的网关补成带协议路径后缀的 binding 端点(后缀已存在时不重复追加)。</summary>
public static string BuildBindingEndpoint(string? gateway, string? suffix) {
var trimmed = (gateway ?? string.Empty).TrimEnd('/');
if (string.IsNullOrEmpty(suffix)) {
return trimmed;
}
return trimmed.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) ? trimmed : trimmed + suffix;
}

/// <summary>Human-readable numbered list for bot selection.</summary>
public static string FormatMenu() {
var sb = new System.Text.StringBuilder("请选择要创建的渠道预设:\n");
Expand All @@ -99,6 +197,7 @@
sb.AppendLine($" 备注: {p.Notes}");
}
}

sb.Append("\n发送编号选择;发送 取消 退出。");
return sb.ToString();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public virtual async Task<LlmTransportBundle> GetTransport(LLMChannel channel, L
promptCachingEnabled, supportsVision, logger, httpClientFactory);
case LlmProtocol.AnthropicMessages:
return Transports.AnthropicMessagesTransport.Create(channel, binding, modelName, systemPrompt,
nativeTools, promptCachingEnabled, logger);
nativeTools, promptCachingEnabled, logger, chatId);
case LlmProtocol.Gemini:
return Transports.GeminiTransport.Create(channel, binding, modelName, supportsVision, logger, httpClientFactory);
case LlmProtocol.Ollama:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public sealed class AnthropicMessagesTransport : ILlmTransport {
/// construction glue formerly in AnthropicModelApi.CreateClient.
/// </summary>
public static LlmTransportBundle Create(LLMChannel channel, LLMApiBinding binding, string modelName, string systemPrompt,
bool nativeTools, bool promptCachingEnabled, ILogger logger) {
bool nativeTools, bool promptCachingEnabled, ILogger logger, long chatId = 0) {
var apiKey = LlmBindingSupport.ResolveApiKey(channel, binding);
var options = new Anthropic.Core.ClientOptions {
ApiKey = apiKey,
Expand All @@ -40,6 +40,11 @@ public static LlmTransportBundle Create(LLMChannel channel, LLMApiBinding bindin
}
options.BaseUrl = trimmed;
}

// OpenCode Go 要求每个请求携带稳定的 x-opencode-session(否则 400 MissingSessionID);
// 非 opencode 端点时 Apply 自会跳过,其它渠道无感。
options.HttpClient = CreateSessionHttpClient(endpoint, chatId);

var transport = new AnthropicMessagesTransport(logger, new AnthropicClient(options), systemPrompt, modelName, channel,
nativeTools, promptCachingEnabled);
var config = new LlmTransportConfig {
Expand All @@ -54,6 +59,16 @@ public static LlmTransportBundle Create(LLMChannel channel, LLMApiBinding bindin
return new LlmTransportBundle(transport, config);
}

/// <summary>
/// 构造带 OpenCode 会话头的 HttpClient(opencode 端点生效,其它端点原样)。
/// 单独抽出以便测试验证 Go 的 x-opencode-session 行为。
/// </summary>
internal static HttpClient CreateSessionHttpClient(string? endpoint, long chatId = 0) {
var httpClient = new HttpClient();
OpencodeSessionHeaders.Apply(httpClient, endpoint, chatId > 0 ? $"tsb-{chatId}" : OpencodeSessionHeaders.GlobalSessionId);
return httpClient;
}

private readonly ILogger _logger;
private readonly AnthropicClient _client;
private readonly string _systemPrompt;
Expand Down
45 changes: 45 additions & 0 deletions TelegramSearchBot.Test/Manage/EditLLMConfTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -508,5 +508,50 @@ public async Task ExecuteAsync_SetMaxImageRetryCount() {
Assert.NotNull(config);
Assert.Equal("500", config.Value);
}

[Fact]
public async Task ExecuteAsync_PresetOpenCodeGo_CreatesResponseBindingAndAssignsModels() {
// Arrange
long chatId = 123;
var stateKey = $"llmconf:{chatId}:state";
var dataKey = $"llmconf:{chatId}:data";
var goIndex = LlmProviderCatalog.Presets.ToList().FindIndex(p => p.Id == "opencode-go") + 1;

_dbMock.SetupSequence(d => d.StringGetAsync(stateKey, It.IsAny<CommandFlags>()))
.ReturnsAsync(RedisValue.Null)
.ReturnsAsync("awaiting_preset_selection")
.ReturnsAsync("awaiting_preset_apikey");
_dbMock.SetupSequence(d => d.StringGetAsync(dataKey, It.IsAny<CommandFlags>()))
.ReturnsAsync("opencode-go");

helperMock.Setup(h => h.AddChannel(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<LLMProvider>(), It.IsAny<int>(), It.IsAny<int>()))
.ReturnsAsync(7);
helperMock.Setup(h => h.AddModelWithChannel(It.IsAny<int>(), It.IsAny<List<string>>()))
.ReturnsAsync(true);
helperMock.Setup(h => h.EnsureBinding(It.IsAny<int>(), It.IsAny<string>(), It.IsAny<LlmProtocol>(), It.IsAny<LlmAuthProfile>()))
.ReturnsAsync(100);
helperMock.Setup(h => h.AssignModelBinding(It.IsAny<int>(), It.IsAny<string>(), It.IsAny<int>()))
.ReturnsAsync(true);

// Act
var result1 = await _service.ExecuteAsync("预制渠道", chatId);
Assert.True(result1.Item1);
Assert.Contains("OpenCode Go", result1.Item2);

var result2 = await _service.ExecuteAsync(goIndex.ToString(), chatId);
Assert.True(result2.Item1);
Assert.Contains("API Key", result2.Item2);

var result3 = await _service.ExecuteAsync("go-token", chatId);

// Assert
Assert.True(result3.Item1);
Assert.Contains("渠道创建成功", result3.Item2);
helperMock.Verify(h => h.EnsureBinding(7, "https://opencode.ai/zen/go/v1", LlmProtocol.OpenAIResponses, LlmAuthProfile.Bearer), Times.Once);
helperMock.Verify(h => h.AssignModelBinding(7, "grok-4.6", 100), Times.Once);
helperMock.Verify(h => h.AssignModelBinding(7, "gpt-5.6-luna", 100), Times.Once);
helperMock.Verify(h => h.AssignModelBinding(7, "glm-5.3", It.IsAny<int>()), Times.Never);
helperMock.Verify(h => h.AssignModelBinding(7, It.IsAny<string>(), It.IsAny<int>()), Times.Exactly(2));
}
}
}
Loading
Loading