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
Expand Up @@ -202,6 +202,43 @@ await client.GetResponseAsync(
Assert.Contains("\"role\":\"tool\"", body, StringComparison.Ordinal);
}

[Fact]
public async Task ExtractsToolCalls_FromTextBasedFormat_WhenModelSkipsStructuredFormat()
{
var textWithToolCall = """
Let me save that for you.
<tool_call>
<function=store_memory>
<parameter=Content>Important note</parameter>
<parameter=Domain>project:test</parameter>
</function>
</tool_call>
""";

var responseJson = $"{{\"id\":\"1\",\"model\":\"test\",\"choices\":[{{\"finish_reason\":\"stop\",\"message\":{{\"role\":\"assistant\",\"content\":{System.Text.Json.JsonSerializer.Serialize(textWithToolCall)}}}}}]}}";

using var handler = new RecordingHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responseJson, Encoding.UTF8, "application/json")
});
using var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost:8000") };
var endpoint = OpenAiCompatibleEndpoint.FromBaseUrl("http://localhost:8000");
var client = new OpenAiCompatibleChatClient(httpClient, endpoint, "test-model");

var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "save this")]);

var toolCall = Assert.Single(response.Messages[^1].Contents.OfType<FunctionCallContent>());
Assert.Equal("store_memory", toolCall.Name);
Assert.Equal("Important note", toolCall.Arguments!["Content"]?.ToString());
Assert.Equal("project:test", toolCall.Arguments!["Domain"]?.ToString());
Assert.Equal(ChatFinishReason.ToolCalls, response.FinishReason);

var remainingText = response.Messages[^1].Contents.OfType<TextContent>().FirstOrDefault();
Assert.NotNull(remainingText);
Assert.Contains("Let me save that", remainingText.Text);
Assert.DoesNotContain("<tool_call>", remainingText.Text);
}

private sealed class RecordingHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
Expand Down
53 changes: 50 additions & 3 deletions src/Netclaw.OpenAICompatible/OpenAiCompatibleChatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,23 +54,53 @@ public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var reader = new StreamReader(stream, Encoding.UTF8);
var pendingToolCalls = new Dictionary<int, PendingToolCall>();
var accumulatedText = new StringBuilder();
var hadStructuredToolCalls = false;
ChatResponseUpdate? finalUpdate = null;

while (!cancellationToken.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(cancellationToken);
if (line is null)
yield break;
break;

if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data:", StringComparison.Ordinal))
continue;

var ssePayload = line[5..].Trim();
if (ssePayload == "[DONE]")
yield break;
break;

using var document = JsonDocument.Parse(ssePayload);
foreach (var update in ParseStreamingUpdates(document.RootElement, pendingToolCalls))
{
foreach (var tc in update.Contents.OfType<TextContent>())
accumulatedText.Append(tc.Text);

if (update.Contents.OfType<FunctionCallContent>().Any())
hadStructuredToolCalls = true;

if (update.FinishReason is not null)
finalUpdate = update;

yield return update;
}
}

// Fallback: if the model stopped without structured tool calls but the text
// contains XML-like tool call blocks, emit a synthetic tool call update.
if (!hadStructuredToolCalls
&& finalUpdate?.FinishReason != ChatFinishReason.ToolCalls
&& accumulatedText.Length > 0)
{
var textToolCalls = TextToolCallParser.ExtractFromText(accumulatedText.ToString());
if (textToolCalls.Count > 0)
{
yield return new ChatResponseUpdate(ChatRole.Assistant, textToolCalls.Cast<AIContent>().ToList())
{
FinishReason = ChatFinishReason.ToolCalls
};
}
}
}

Expand Down Expand Up @@ -251,6 +281,7 @@ private static ChatResponse ParseChatResponse(JsonElement root)
var choice = root.GetProperty("choices")[0];
var message = choice.GetProperty("message");
var contents = new List<AIContent>();
var finishReason = ParseFinishReason(choice);

if (message.TryGetProperty("reasoning_content", out var reasoning)
&& reasoning.ValueKind == JsonValueKind.String)
Expand All @@ -264,11 +295,27 @@ private static ChatResponse ParseChatResponse(JsonElement root)
contents.Add(new TextContent(content.GetString()!));
}

// Fallback: extract tool calls from text when model uses XML-like format
if (finishReason != ChatFinishReason.ToolCalls)
{
var textContent = content.ValueKind == JsonValueKind.String ? content.GetString() : null;
var textToolCalls = TextToolCallParser.ExtractFromText(textContent);
if (textToolCalls.Count > 0)
{
contents.RemoveAll(c => c is TextContent);
var cleaned = TextToolCallParser.StripToolCallText(textContent!);
if (!string.IsNullOrWhiteSpace(cleaned))
contents.Add(new TextContent(cleaned));
contents.AddRange(textToolCalls);
finishReason = ChatFinishReason.ToolCalls;
}
}

return new ChatResponse(new ChatMessage(ChatRole.Assistant, contents))
{
ModelId = root.TryGetProperty("model", out var model) ? model.GetString() : null,
ResponseId = root.TryGetProperty("id", out var id) ? id.GetString() : null,
FinishReason = ParseFinishReason(choice)
FinishReason = finishReason
};
}

Expand Down
69 changes: 69 additions & 0 deletions src/Netclaw.OpenAICompatible/TextToolCallParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.AI;

namespace Netclaw.OpenAICompatible;

/// <summary>
/// Extracts structured tool calls from LLM text responses when the model emits
/// tool calls as text (e.g. Qwen3.5's XML-like format) instead of using the
/// OpenAI-structured tool_calls response field.
/// </summary>
internal static partial class TextToolCallParser
{
/// <summary>
/// Attempts to extract tool calls from a text string.
/// Returns an empty list if no text-based tool calls are found.
/// </summary>
public static List<FunctionCallContent> ExtractFromText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return [];

var results = new List<FunctionCallContent>();

foreach (Match block in ToolCallBlockRegex().Matches(text))
{
var functionMatch = FunctionNameRegex().Match(block.Value);
if (!functionMatch.Success)
continue;

var functionName = functionMatch.Groups[1].Value;
var arguments = new Dictionary<string, object?>();

foreach (Match param in ParameterRegex().Matches(block.Value))
{
arguments[param.Groups[1].Value] = param.Groups[2].Value.Trim();
}

results.Add(new FunctionCallContent(
Guid.NewGuid().ToString("N"),
functionName,
arguments));
}

return results;
}

/// <summary>
/// Removes text-based tool call blocks from the text, returning
/// the remaining content (if any).
/// </summary>
public static string StripToolCallText(string text)
{
return ToolCallBlockRegex().Replace(text, "").Trim();
}

// Matches the entire <tool_call>...</tool_call> block
[GeneratedRegex(@"<tool_call>\s*<function=([^>]+)>\s*(.*?)\s*</function>\s*</tool_call>",
RegexOptions.Singleline | RegexOptions.Compiled)]
private static partial Regex ToolCallBlockRegex();

// Extracts the function name from <function=NAME>
[GeneratedRegex(@"<function=([^>]+)>", RegexOptions.Compiled)]
private static partial Regex FunctionNameRegex();

// Extracts parameter name and value from <parameter=KEY>VALUE</parameter>
[GeneratedRegex(@"<parameter=([^>]+)>(.*?)</parameter>",
RegexOptions.Singleline | RegexOptions.Compiled)]
private static partial Regex ParameterRegex();
}
Loading