Skip to content
Open
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
12 changes: 10 additions & 2 deletions src/OpenDeepWiki/Agents/AgentFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,19 @@ public class AgentFactory(IOptions<AiRequestOptions> options)
private readonly AiRequestOptions? _options = options?.Value;

/// <summary>
/// 创建带拦截功能的 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.
/// </summary>
private static HttpClient CreateHttpClient()
{
var handler = new LoggingHttpHandler();
var handler = new FinishReasonNormalizingHandler(
new LoggingHttpHandler(
new HttpClientHandler()));
return new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(300)
Expand Down
229 changes: 229 additions & 0 deletions src/OpenDeepWiki/Agents/FinishReasonNormalizingHandler.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A <see cref="DelegatingHandler"/> that normalizes the <c>finish_reason</c> 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
/// <see cref="ArgumentOutOfRangeException"/> ("Unknown ChatFinishReason value") whenever
/// Gemini returns a non-OpenAI finish-reason string in a streaming completion.
/// </summary>
public sealed class FinishReasonNormalizingHandler : DelegatingHandler
{
private static readonly Serilog.ILogger Logger = Serilog.Log.ForContext<FinishReasonNormalizingHandler>();

// 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<HttpResponseMessage> 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;
}

/// <summary>
/// Normalizes a single <c>finish_reason</c> value to the OpenAI SDK's expected set.
/// The mapping is case-insensitive on the input.
///
/// Exposed as <c>internal static</c> so unit tests can call it directly.
/// </summary>
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"
}
};
}

/// <summary>
/// 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 <c>internal static</c> for unit tests.
/// </summary>
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

/// <summary>
/// An <see cref="HttpContent"/> implementation that wraps the original SSE content
/// and transforms it line-by-line on the fly without buffering the entire body.
/// </summary>
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);
}
}
}
144 changes: 144 additions & 0 deletions tests/OpenDeepWiki.Tests/Agents/FinishReasonNormalizingHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
using OpenDeepWiki.Agents;
using Xunit;

namespace OpenDeepWiki.Tests.Agents;

/// <summary>
/// Unit tests for <see cref="FinishReasonNormalizingHandler"/>.
/// Exercises the internal static helpers directly so no HTTP stack is required.
/// </summary>
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);
}
}