From a2acc0490d05827bf7f531e7625a0139124c6bd8 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Fri, 25 Jul 2025 13:37:49 +0200 Subject: [PATCH 1/2] Adds support for logging LM token usage for streamed responses. Closes #1345 --- .../Inspection/OpenAITelemetryPlugin.cs | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs b/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs index d560196a..8c3c6188 100644 --- a/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs +++ b/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs @@ -19,6 +19,7 @@ using System.Diagnostics; using System.Diagnostics.Metrics; using System.Text.Json; +using Titanium.Web.Proxy.Http; namespace DevProxy.Plugins.Inspection; @@ -321,7 +322,13 @@ private void ProcessSuccessResponse(Activity activity, ProxyResponseArgs e) return; } - AddResponseTypeSpecificTags(activity, openAiRequest, response.BodyString); + var bodyString = response.BodyString; + if (IsStreamingResponse(response)) + { + bodyString = GetBodyFromStreamingResponse(response); + } + + AddResponseTypeSpecificTags(activity, openAiRequest, bodyString); Logger.LogTrace("ProcessSuccessResponse() finished"); } @@ -997,6 +1004,63 @@ private static string GetOperationName(OpenAIRequest request) }; } + private bool IsStreamingResponse(Response response) + { + Logger.LogTrace("{Method} called", nameof(IsStreamingResponse)); + var contentType = response.Headers.FirstOrDefault(h => h.Name.Equals("content-type", StringComparison.OrdinalIgnoreCase))?.Value; + if (string.IsNullOrEmpty(contentType)) + { + Logger.LogDebug("No content-type header found"); + return false; + } + + var isStreamingResponse = contentType.Contains("text/event-stream", StringComparison.OrdinalIgnoreCase); + Logger.LogDebug("IsStreamingResponse: {IsStreamingResponse}", isStreamingResponse); + + Logger.LogTrace("{Method} finished", nameof(IsStreamingResponse)); + return isStreamingResponse; + } + + private string GetBodyFromStreamingResponse(Response response) + { + Logger.LogTrace("{Method} called", nameof(GetBodyFromStreamingResponse)); + + // default to the whole body + var bodyString = response.BodyString; + + var chunks = bodyString.Split("\n\n", StringSplitOptions.RemoveEmptyEntries); + if (chunks.Length == 0) + { + Logger.LogDebug("No chunks found in the response body"); + return bodyString; + } + + // check if the last chunk is `data: [DONE]` + var lastChunk = chunks.Last().Trim(); + if (lastChunk.Equals("data: [DONE]", StringComparison.OrdinalIgnoreCase)) + { + // get next to last chunk + var chunk = chunks.Length > 1 ? chunks[^2].Trim() : string.Empty; + if (chunk.StartsWith("data: ", StringComparison.OrdinalIgnoreCase)) + { + // remove the "data: " prefix + bodyString = chunk[6..].Trim(); + Logger.LogDebug("Last chunk starts with 'data: ', using the last chunk as the body: {BodyString}", bodyString); + } + else + { + Logger.LogDebug("Last chunk does not start with 'data: ', using the whole body"); + } + } + else + { + Logger.LogDebug("Last chunk is not `data: [DONE]`, using the whole body"); + } + + Logger.LogTrace("{Method} finished", nameof(GetBodyFromStreamingResponse)); + return bodyString; + } + public void Dispose() { _loader?.Dispose(); From 95e574ae52b19bdcf98f14768a5f986f382db5d9 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Fri, 25 Jul 2025 14:20:22 +0200 Subject: [PATCH 2/2] Update DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs b/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs index 8c3c6188..7707cdf5 100644 --- a/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs +++ b/DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs @@ -1044,7 +1044,7 @@ private string GetBodyFromStreamingResponse(Response response) if (chunk.StartsWith("data: ", StringComparison.OrdinalIgnoreCase)) { // remove the "data: " prefix - bodyString = chunk[6..].Trim(); + bodyString = chunk["data: ".Length..].Trim(); Logger.LogDebug("Last chunk starts with 'data: ', using the last chunk as the body: {BodyString}", bodyString); } else