diff --git a/src/OpenDeepWiki/Agents/AgentFactory.cs b/src/OpenDeepWiki/Agents/AgentFactory.cs index c8829710..1cf6afdb 100644 --- a/src/OpenDeepWiki/Agents/AgentFactory.cs +++ b/src/OpenDeepWiki/Agents/AgentFactory.cs @@ -63,11 +63,19 @@ public class AgentFactory(IOptions options) private readonly AiRequestOptions? _options = options?.Value; /// - /// 创建带拦截功能的 HttpClient + /// Creates an HttpClient with the full handler chain: + /// FinishReasonNormalizingHandler -> LoggingHttpHandler -> HttpClientHandler + /// + /// FinishReasonNormalizingHandler is outermost so it transforms the SSE response + /// AFTER LoggingHttpHandler's retry logic has delivered the final response. + /// This ensures Gemini's non-OpenAI finish_reason values (STOP, MAX_TOKENS, + /// SAFETY, etc.) are mapped to the OpenAI SDK's expected set before deserialization. /// private static HttpClient CreateHttpClient() { - var handler = new LoggingHttpHandler(); + var handler = new FinishReasonNormalizingHandler( + new LoggingHttpHandler( + new HttpClientHandler())); return new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(300) diff --git a/src/OpenDeepWiki/Agents/FinishReasonNormalizingHandler.cs b/src/OpenDeepWiki/Agents/FinishReasonNormalizingHandler.cs new file mode 100644 index 00000000..ea20adec --- /dev/null +++ b/src/OpenDeepWiki/Agents/FinishReasonNormalizingHandler.cs @@ -0,0 +1,229 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.RegularExpressions; + + +namespace OpenDeepWiki.Agents; + +/// +/// A that normalizes the finish_reason field in +/// SSE (text/event-stream) responses so that Gemini's OpenAI-compatible endpoint values +/// (e.g. STOP, MAX_TOKENS, SAFETY, RECITATION, OTHER) are mapped to the OpenAI SDK's +/// expected set (stop, length, tool_calls, function_call, content_filter). +/// +/// Without this handler the OpenAI .NET SDK v2.x throws +/// ("Unknown ChatFinishReason value") whenever +/// Gemini returns a non-OpenAI finish-reason string in a streaming completion. +/// +public sealed class FinishReasonNormalizingHandler : DelegatingHandler +{ + private static readonly Serilog.ILogger Logger = Serilog.Log.ForContext(); + + // Regex matches: "finish_reason" : "VALUE" + // - does NOT match finish_reason: null (no quotes around null) + // - we use a non-greedy [^"]+ to avoid crossing into the next JSON field + private static readonly Regex FinishReasonRegex = + new(@"""finish_reason""\s*:\s*""([^""]+)""", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public FinishReasonNormalizingHandler(HttpMessageHandler innerHandler) + : base(innerHandler) + { + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + HttpResponseMessage response = await base.SendAsync(request, cancellationToken); + + try + { + if (IsSseResponse(response)) + { + response.Content = WrapSseContent(response.Content); + } + } + catch (Exception ex) + { + // Never break the call - fall through with the original response + Logger.Warning(ex, + "FinishReasonNormalizingHandler: failed to wrap SSE content; returning original response."); + } + + return response; + } + + private static bool IsSseResponse(HttpResponseMessage response) + { + if (!response.IsSuccessStatusCode) + { + return false; + } + + var ct = response.Content.Headers.ContentType; + return ct is not null && + ct.MediaType is not null && + ct.MediaType.Equals("text/event-stream", StringComparison.OrdinalIgnoreCase); + } + + private static HttpContent WrapSseContent(HttpContent original) + { + // Read the original stream lazily; wrap it in a transforming stream. + // We use a callback-based StreamContent so the original stream is only + // read once and is never fully buffered in memory. + var transforming = new TransformingStreamContent(original); + + // Copy all content headers from the original so Content-Type / encoding + // are preserved downstream. + foreach (var header in original.Headers) + { + transforming.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + return transforming; + } + + /// + /// Normalizes a single finish_reason value to the OpenAI SDK's expected set. + /// The mapping is case-insensitive on the input. + /// + /// Exposed as internal static so unit tests can call it directly. + /// + internal static string NormalizeFinishReason(string raw) + { + // Already valid OpenAI values - fast path (most common for non-Gemini endpoints) + return raw switch + { + "stop" => "stop", + "length" => "length", + "tool_calls" => "tool_calls", + "function_call" => "function_call", + "content_filter" => "content_filter", + _ => raw.ToUpperInvariant() switch + { + // stop-like + "STOP" or "END_TURN" => "stop", + // length-like + "LENGTH" or "MAX_TOKENS" or "MAX_TOKEN" => "length", + // tool/function + "TOOL_CALLS" => "tool_calls", + "FUNCTION_CALL" => "function_call", + // safety / filter + "CONTENT_FILTER" or "SAFETY" or "RECITATION" + or "BLOCKLIST" or "PROHIBITED_CONTENT" or "SPII" => "content_filter", + // everything else (OTHER, MALFORMED_FUNCTION_CALL, unknown) -> stop + _ => "stop" + } + }; + } + + /// + /// Transforms a single SSE data line, replacing any Gemini-native finish_reason + /// with its OpenAI equivalent. Lines without finish_reason (or with null) are + /// returned unchanged. + /// + /// Exposed as internal static for unit tests. + /// + internal static string TransformLine(string line) + { + if (!line.StartsWith("data:", StringComparison.Ordinal)) + { + return line; + } + + // Quick check before running regex + if (!line.Contains("\"finish_reason\"", StringComparison.Ordinal)) + { + return line; + } + + return FinishReasonRegex.Replace(line, match => + { + var rawValue = match.Groups[1].Value; + var normalized = NormalizeFinishReason(rawValue); + if (string.Equals(rawValue, normalized, StringComparison.Ordinal)) + { + return match.Value; // no-op, avoid allocation + } + + Logger.Debug( + "FinishReasonNormalizingHandler: normalized finish_reason {Raw} -> {Normalized}", + rawValue, normalized); + + // Reconstruct: replace only the captured group, keeping surrounding JSON intact + return match.Value.Replace($"\"{rawValue}\"", $"\"{normalized}\""); + }); + } + + // --------------------------------------------------------------------- inner types + + /// + /// An implementation that wraps the original SSE content + /// and transforms it line-by-line on the fly without buffering the entire body. + /// + private sealed class TransformingStreamContent : HttpContent + { + private readonly HttpContent _inner; + + public TransformingStreamContent(HttpContent inner) + { + _inner = inner; + } + + protected override async Task SerializeToStreamAsync( + Stream stream, + TransportContext? context) + { + var innerStream = await _inner.ReadAsStreamAsync(); + await TransformSseStreamAsync(innerStream, stream, CancellationToken.None); + } + + protected override async Task SerializeToStreamAsync( + Stream stream, + TransportContext? context, + CancellationToken cancellationToken) + { + var innerStream = await _inner.ReadAsStreamAsync(cancellationToken); + await TransformSseStreamAsync(innerStream, stream, cancellationToken); + } + + protected override bool TryComputeLength(out long length) + { + // Length unknown until we transform; signal that to the framework + length = -1; + return false; + } + + private static async Task TransformSseStreamAsync( + Stream source, + Stream destination, + CancellationToken cancellationToken) + { + // We need to split the stream on '\n' while preserving '\r\n' vs '\n' + // line endings, hold any partial (not-yet-terminated) line, and flush + // it at end-of-stream. We do this with a small ring buffer rather than + // ReadLineAsync so we keep full control of newline bytes. + + var reader = new StreamReader(source, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, + bufferSize: 4096, leaveOpen: true); + // Use a writer that does NOT add a BOM and flushes after each write so the + // HTTP client can stream the data to the OpenAI SDK progressively. + var writer = new StreamWriter(destination, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + bufferSize: 4096, leaveOpen: true) { AutoFlush = true }; + + string? line; + while ((line = await reader.ReadLineAsync(cancellationToken)) is not null) + { + var transformed = TransformLine(line); + // ReadLineAsync strips the newline; we must restore it so the OpenAI + // SDK receives properly framed SSE events. + await writer.WriteLineAsync(transformed.AsMemory(), cancellationToken); + } + + await writer.FlushAsync(cancellationToken); + } + } +} diff --git a/tests/OpenDeepWiki.Tests/Agents/FinishReasonNormalizingHandlerTests.cs b/tests/OpenDeepWiki.Tests/Agents/FinishReasonNormalizingHandlerTests.cs new file mode 100644 index 00000000..fa5efb61 --- /dev/null +++ b/tests/OpenDeepWiki.Tests/Agents/FinishReasonNormalizingHandlerTests.cs @@ -0,0 +1,144 @@ +using OpenDeepWiki.Agents; +using Xunit; + +namespace OpenDeepWiki.Tests.Agents; + +/// +/// Unit tests for . +/// Exercises the internal static helpers directly so no HTTP stack is required. +/// +public class FinishReasonNormalizingHandlerTests +{ + // ------------------------------------------------------------------ + // NormalizeFinishReason – value mapping + // ------------------------------------------------------------------ + + [Theory] + // OpenAI native values pass through unchanged + [InlineData("stop", "stop")] + [InlineData("length", "length")] + [InlineData("tool_calls", "tool_calls")] + [InlineData("function_call", "function_call")] + [InlineData("content_filter", "content_filter")] + // Gemini UPPERCASE stop-like + [InlineData("STOP", "stop")] + [InlineData("END_TURN", "stop")] + // Gemini UPPERCASE length-like + [InlineData("MAX_TOKENS", "length")] + [InlineData("MAX_TOKEN", "length")] + [InlineData("LENGTH", "length")] + // Gemini tool/function + [InlineData("TOOL_CALLS", "tool_calls")] + [InlineData("FUNCTION_CALL", "function_call")] + // Gemini safety / content_filter + [InlineData("SAFETY", "content_filter")] + [InlineData("RECITATION", "content_filter")] + [InlineData("BLOCKLIST", "content_filter")] + [InlineData("PROHIBITED_CONTENT", "content_filter")] + [InlineData("SPII", "content_filter")] + [InlineData("CONTENT_FILTER", "content_filter")] + // Unknown values fall back to "stop" + [InlineData("OTHER", "stop")] + [InlineData("MALFORMED_FUNCTION_CALL", "stop")] + [InlineData("completely_unknown_value", "stop")] + public void NormalizeFinishReason_Maps_Correctly(string input, string expected) + { + var result = FinishReasonNormalizingHandler.NormalizeFinishReason(input); + Assert.Equal(expected, result); + } + + // ------------------------------------------------------------------ + // TransformLine – line-level SSE transformation + // ------------------------------------------------------------------ + + [Fact] + public void TransformLine_Gemini_MAX_TOKENS_Becomes_length() + { + const string line = """data: {"id":"chatcmpl-1","choices":[{"finish_reason":"MAX_TOKENS","delta":{}}]}"""; + var result = FinishReasonNormalizingHandler.TransformLine(line); + Assert.Contains("\"finish_reason\":\"length\"", result); + } + + [Fact] + public void TransformLine_Gemini_STOP_Becomes_stop() + { + const string line = """data: {"choices":[{"finish_reason":"STOP","delta":{}}]}"""; + var result = FinishReasonNormalizingHandler.TransformLine(line); + Assert.Contains("\"finish_reason\":\"stop\"", result); + } + + [Fact] + public void TransformLine_Gemini_MALFORMED_FUNCTION_CALL_Becomes_stop() + { + const string line = """data: {"choices":[{"finish_reason":"MALFORMED_FUNCTION_CALL","delta":{}}]}"""; + var result = FinishReasonNormalizingHandler.TransformLine(line); + Assert.Contains("\"finish_reason\":\"stop\"", result); + } + + [Fact] + public void TransformLine_Null_FinishReason_IsUnchanged() + { + // finish_reason: null must NOT be touched (no quotes, regex won't match) + const string line = """data: {"choices":[{"finish_reason":null,"delta":{}}]}"""; + var result = FinishReasonNormalizingHandler.TransformLine(line); + Assert.Equal(line, result); + } + + [Fact] + public void TransformLine_Lowercase_stop_IsUnchanged() + { + // Already a valid OpenAI value - should pass through without modification + const string line = """data: {"choices":[{"finish_reason":"stop","delta":{}}]}"""; + var result = FinishReasonNormalizingHandler.TransformLine(line); + Assert.Equal(line, result); + } + + [Fact] + public void TransformLine_NonDataLine_IsUnchanged() + { + const string line = "event: chat.completion.chunk"; + var result = FinishReasonNormalizingHandler.TransformLine(line); + Assert.Equal(line, result); + } + + [Fact] + public void TransformLine_DoneSentinel_IsUnchanged() + { + const string line = "data: [DONE]"; + var result = FinishReasonNormalizingHandler.TransformLine(line); + Assert.Equal(line, result); + } + + [Fact] + public void TransformLine_EmptyLine_IsUnchanged() + { + var result = FinishReasonNormalizingHandler.TransformLine(string.Empty); + Assert.Equal(string.Empty, result); + } + + [Fact] + public void TransformLine_DataLine_WithoutFinishReason_IsUnchanged() + { + const string line = """data: {"choices":[{"delta":{"content":"hello"}}]}"""; + var result = FinishReasonNormalizingHandler.TransformLine(line); + Assert.Equal(line, result); + } + + [Fact] + public void TransformLine_Gemini_SAFETY_Becomes_content_filter() + { + const string line = """data: {"choices":[{"finish_reason":"SAFETY","delta":{}}]}"""; + var result = FinishReasonNormalizingHandler.TransformLine(line); + Assert.Contains("\"finish_reason\":\"content_filter\"", result); + } + + [Fact] + public void TransformLine_SpaceAroundColon_IsHandled() + { + // Regex uses \s* so whitespace around colon is tolerated + const string line = """data: {"choices":[{"finish_reason" : "MAX_TOKENS","delta":{}}]}"""; + var result = FinishReasonNormalizingHandler.TransformLine(line); + Assert.Contains("\"finish_reason\"", result); + Assert.Contains("\"length\"", result); + } +}