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
@@ -1,9 +1,11 @@
using TelegramSearchBot.Model;
using TelegramSearchBot.Model.AI;
using TelegramSearchBot.Service.AI.LLM;
using TelegramSearchBot.Test;
using Xunit;

namespace TelegramSearchBot.LLM.Test.Service.AI.LLM {
[Collection(McpToolHelperTestCollection.Name)]
public class McpToolHelperProxyTests {
[Fact]
public async Task ExecuteRegisteredToolAsync_ProxyTool_ForwardsToolContextMetadata() {
Expand Down Expand Up @@ -46,5 +48,48 @@ public async Task ExecuteRegisteredToolAsync_ProxyTool_ForwardsToolContextMetada
Assert.Equal("456", forwardedArguments["__userId"]);
Assert.Equal("789", forwardedArguments["__messageId"]);
}

[Fact]
public void TryParseToolCalls_ProxyToolXml_ParsesRegisteredProxyTool() {
var toolName = $"proxy_parse_test_{Guid.NewGuid():N}";

McpToolHelper.RegisterProxyTools([
new ProxyToolDefinition {
Name = toolName,
Description = "Proxy search tool.",
Parameters = [
new ProxyToolParameter {
Name = "query",
Type = "string",
Description = "Query text.",
Required = true
},
new ProxyToolParameter {
Name = "count",
Type = "integer",
Description = "Result count.",
Required = false
}
]
}
], (_, _) => Task.FromResult("ok"));

var xml = $"""
<tool name="{toolName}">
<parameters>
<query>苏州今日天气</query>
<count>5</count>
</parameters>
</tool>
""";

var parsed = McpToolHelper.TryParseToolCalls(xml, out var parsedToolCalls);

Assert.True(parsed);
Assert.Single(parsedToolCalls);
Assert.Equal(toolName, parsedToolCalls[0].toolName);
Assert.Equal("苏州今日天气", parsedToolCalls[0].arguments["query"]);
Assert.Equal("5", parsedToolCalls[0].arguments["count"]);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using TelegramSearchBot.Service.AI.LLM;
using TelegramSearchBot.Model.AI;
using TelegramSearchBot.Model.Data;
using Xunit;

namespace TelegramSearchBot.LLM.Test.Service.AI.LLM {
Expand Down Expand Up @@ -61,5 +63,35 @@ public void DeserializeToolArgumentsForDisplay_InvalidJson_ReturnsEmptyDictionar

Assert.Empty(arguments);
}

[Fact]
public void IsMiniMaxCompatibleEndpoint_MiniMaxProvider_ReturnsTrue() {
var channel = new LLMChannel {
Provider = LLMProvider.MiniMax,
Gateway = "https://api.minimaxi.com/v1"
};

Assert.True(OpenAIService.IsMiniMaxCompatibleEndpoint(channel, "MiniMax-M2.7"));
}

[Fact]
public void IsMiniMaxCompatibleEndpoint_MiniMaxGateway_ReturnsTrue() {
var channel = new LLMChannel {
Provider = LLMProvider.OpenAI,
Gateway = "https://api.minimaxi.com/v1"
};

Assert.True(OpenAIService.IsMiniMaxCompatibleEndpoint(channel, "some-model"));
}

[Fact]
public void IsMiniMaxCompatibleEndpoint_OpenAIProvider_ReturnsFalse() {
var channel = new LLMChannel {
Provider = LLMProvider.OpenAI,
Gateway = "https://api.openai.com/v1"
};

Assert.False(OpenAIService.IsMiniMaxCompatibleEndpoint(channel, "gpt-4.1"));
}
}
}
54 changes: 37 additions & 17 deletions TelegramSearchBot.LLM/Service/AI/LLM/McpToolHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ public static bool TryParseToolCalls(string input, out List<(string toolName, Di
// Check if there's a wrapper element containing tool elements
if (doc.Root.Elements().Any(e =>
e.Name.LocalName.Equals("tool", StringComparison.OrdinalIgnoreCase) ||
ToolRegistry.ContainsKey(e.Name.LocalName))) {
IsToolRegistered(e.Name.LocalName))) {
// Process each tool element inside the wrapper
foreach (var toolElement in doc.Root.Elements()) {
var toolCall = ParseToolElement(toolElement);
Expand All @@ -500,11 +500,18 @@ public static bool TryParseToolCalls(string input, out List<(string toolName, Di

// Fallback to regex parsing for individual tool blocks
var toolBlockMatches = Regex.Matches(processedInput, @"<(tool\b[^>]*|[\w]+)(?:\s[^>]*)?>[\s\S]*?<\/\1>", RegexOptions.IgnoreCase);
_sLogger?.LogInformation($"TryParseToolCalls: Found {toolBlockMatches.Count} tool elements in input: {processedInput}");
_sLogger?.LogInformation($"TryParseToolCalls: Raw regex matches - Count: {toolBlockMatches.Count}");
_sLogger?.LogInformation($"TryParseToolCalls: Using regex pattern: {@"<(tool\b[^>]*|[\w]+)(?:\s[^>]*)?>[\s\S]*?<\/\1>"}");
_sLogger?.LogInformation(
"TryParseToolCalls: Found {ToolBlockCount} potential tool elements. BuiltInCount={BuiltInCount}, ExternalCount={ExternalCount}, ProxyCount={ProxyCount}, AvailableTools={AvailableTools}, InputPreview={InputPreview}",
toolBlockMatches.Count,
ToolRegistry.Count,
ExternalToolRegistry.Count,
ProxyToolRegistry.Count,
GetAvailableToolNamesForLog(),
TruncateForLog(processedInput, MaxToolLogPayloadLength));
_sLogger?.LogDebug($"TryParseToolCalls: Raw regex matches - Count: {toolBlockMatches.Count}");
_sLogger?.LogTrace($"TryParseToolCalls: Using regex pattern: {@"<(tool\b[^>]*|[\w]+)(?:\s[^>]*)?>[\s\S]*?<\/\1>"}");
for (int i = 0; i < toolBlockMatches.Count; i++) {
_sLogger?.LogInformation($"Match {i}: {toolBlockMatches[i].Value}");
_sLogger?.LogTrace($"Match {i}: {toolBlockMatches[i].Value}");
}

_sLogger?.LogDebug($"TryParseToolCalls: Found {toolBlockMatches.Count} potential tool blocks using regex.");
Expand Down Expand Up @@ -549,27 +556,30 @@ public static bool TryParseToolCalls(string input, out List<(string toolName, Di

private static (string toolName, Dictionary<string, string> arguments) ParseToolElement(XElement element) {
string toolName = null;
var requestedToolName = element.Name.LocalName;
var arguments = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

// 确定工具名称 - 更灵活的匹配逻辑
if (element.Name.LocalName.Equals("tool", StringComparison.OrdinalIgnoreCase)) {
toolName = element.Attribute("name")?.Value;
if (string.IsNullOrEmpty(toolName)) {
requestedToolName = element.Attribute("name")?.Value;
if (string.IsNullOrEmpty(requestedToolName)) {
_sLogger?.LogWarning("ParseToolElement: Tool element has no name attribute");
return (null, null);
}
toolName = ResolveRegisteredToolName(requestedToolName);
} else {
// 尝试匹配注册的工具名称 (built-in and external)
toolName = ToolRegistry.Keys.FirstOrDefault(k =>
k.Equals(element.Name.LocalName, StringComparison.OrdinalIgnoreCase));
if (toolName == null) {
toolName = ExternalToolRegistry.Keys.FirstOrDefault(k =>
k.Equals(element.Name.LocalName, StringComparison.OrdinalIgnoreCase));
}
// 尝试匹配注册的工具名称 (built-in, external, or proxy)
toolName = ResolveRegisteredToolName(requestedToolName);
}

if (toolName == null || ( !ToolRegistry.ContainsKey(toolName) && !ExternalToolRegistry.ContainsKey(toolName) )) {
_sLogger?.LogWarning($"ParseToolElement: Unregistered tool '{element.Name.LocalName}'");
if (toolName == null) {
_sLogger?.LogWarning(
"ParseToolElement: Unregistered tool '{ToolName}'. BuiltInCount={BuiltInCount}, ExternalCount={ExternalCount}, ProxyCount={ProxyCount}, AvailableTools={AvailableTools}",
requestedToolName,
ToolRegistry.Count,
ExternalToolRegistry.Count,
ProxyToolRegistry.Count,
GetAvailableToolNamesForLog());
return (null, null);
}

Expand Down Expand Up @@ -698,6 +708,16 @@ public static async Task<object> ExecuteRegisteredToolAsync(string toolName, Dic
return await ExecuteRegisteredToolAsync(toolName, stringArguments, null, toolContext);
}

private static string ResolveRegisteredToolName(string toolName) {
if (string.IsNullOrWhiteSpace(toolName)) {
return null;
}

return ToolRegistry.Keys.FirstOrDefault(k => k.Equals(toolName, StringComparison.OrdinalIgnoreCase))
?? ExternalToolRegistry.Keys.FirstOrDefault(k => k.Equals(toolName, StringComparison.OrdinalIgnoreCase))
?? ProxyToolRegistry.Keys.FirstOrDefault(k => k.Equals(toolName, StringComparison.OrdinalIgnoreCase));
}

/// <summary>
/// Executes a registered tool, optionally using a scoped IServiceProvider for DI resolution.
/// Use the scoped overload when executing tools that require scoped services (e.g., DbContext).
Expand Down Expand Up @@ -1171,7 +1191,7 @@ public static bool IsExternalTool(string toolName) {
/// Check if a tool name is registered (built-in, external, or proxy).
/// </summary>
public static bool IsToolRegistered(string toolName) {
return ToolRegistry.ContainsKey(toolName) || ExternalToolRegistry.ContainsKey(toolName) || ProxyToolRegistry.ContainsKey(toolName);
return ResolveRegisteredToolName(toolName) != null;
}

/// <summary>
Expand Down
30 changes: 23 additions & 7 deletions TelegramSearchBot.LLM/Service/AI/LLM/OpenAIResponsesService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -292,13 +292,19 @@ private async IAsyncEnumerable<string> ExecWithResponsesApiAsync(
throw;
}

// Format tool call display
var argsDict = OpenAIService.DeserializeToolArgumentsForDisplay(argsJson);
toolIndicators.Append(McpToolHelper.FormatToolCallDisplay(name, argsDict));

// Execute tool
_logger.LogInformation("{ServiceName}: Responses API tool call: {ToolName} with arguments: {Arguments}",
ServiceName, name, argsJson);
_logger.LogInformation(
"{ServiceName}: Responses API tool call parsed; executing now. Tool={ToolName}, CallId={CallId}, ChatId={ChatId}, UserId={UserId}, MessageId={MessageId}, Arguments={Arguments}",
ServiceName,
name,
callId,
ChatId,
message.FromUserId,
message.MessageId,
argsJson);

string toolResultString;
try {
Expand Down Expand Up @@ -508,13 +514,18 @@ public async IAsyncEnumerable<string> ResumeFromSnapshotAsync(
}

var argsDict = OpenAIService.DeserializeToolArgumentsForDisplay(argsJson);
var toolIndicator = McpToolHelper.FormatToolCallDisplay(name, argsDict);
newContentBuilder.Append(toolIndicator);
fullContentBuilder.Append(toolIndicator);
yield return newContentBuilder.ToString();

string toolResultString;
try {
_logger.LogInformation(
"{ServiceName}: Responses API tool call parsed during resume; executing now. Tool={ToolName}, CallId={CallId}, ChatId={ChatId}, UserId={UserId}, MessageId={MessageId}, Arguments={Arguments}",
ServiceName,
name,
callId,
snapshot.ChatId,
snapshot.UserId,
snapshot.OriginalMessageId,
argsJson);
var toolContext = new ToolContext {
ChatId = snapshot.ChatId,
UserId = snapshot.UserId,
Expand All @@ -535,6 +546,11 @@ public async IAsyncEnumerable<string> ResumeFromSnapshotAsync(
toolResultString = $"Error executing tool {name}: {ex.GetLogSummary()}.";
}

var toolIndicator = McpToolHelper.FormatToolCallDisplay(name, argsDict);
newContentBuilder.Append(toolIndicator);
fullContentBuilder.Append(toolIndicator);
yield return newContentBuilder.ToString();

inputItems.Add(ResponseItem.CreateFunctionCallOutputItem(callId, toolResultString));
}
continue;
Expand Down
Loading
Loading