fix: support LiteLLM Responses streaming
PR and Push Build/Test / build-and-test (push) Successful in 14m17s

This commit is contained in:
2026-07-27 14:44:32 +02:00
parent 3cadf08fa2
commit e192ae7cd8
16 changed files with 682 additions and 541 deletions
@@ -0,0 +1,26 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.AI;
namespace MeetingAssistant.Summary;
internal static class FunctionInvocationGuard
{
public static ValueTask<object?> InvokeAsync(
FunctionInvocationContext context,
CancellationToken cancellationToken)
{
if (context.CallContent.Exception is not null)
{
return ValueTask.FromResult<object?>(new JsonObject
{
["error"] = new JsonObject
{
["code"] = "invalid_tool_arguments",
["message"] = "Tool arguments must be a valid JSON object."
}
});
}
return context.Function.InvokeAsync(context.Arguments, cancellationToken);
}
}
@@ -1,20 +1,28 @@
using System.Net.Http.Headers;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using OpenAI;
using OpenAI.Responses;
namespace MeetingAssistant.Summary;
#pragma warning disable MAAI001
#pragma warning disable OPENAI001
public sealed class LiteLlmResponsesChatClient : IChatClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient httpClient;
private readonly ResponsesClient responsesClient;
private readonly AsyncLocal<string?> requestInitiator = new();
private readonly string model;
private readonly bool useStreaming;
private readonly bool enableThinking;
private readonly string reasoningEffort;
private readonly int reconnectionAttempts;
@@ -37,7 +45,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
ILogger? logger = null,
bool firstRequestIsUser = true,
Action? retrying = null,
Action<string>? reasoningSummaryChanged = null)
Action<string>? reasoningSummaryChanged = null,
bool useStreaming = true)
: this(
new HttpClient { BaseAddress = NormalizeEndpoint(endpoint) },
apiKey,
@@ -50,7 +59,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
logger,
firstRequestIsUser,
retrying,
reasoningSummaryChanged)
reasoningSummaryChanged,
useStreaming)
{
}
@@ -66,11 +76,14 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
ILogger? logger = null,
bool firstRequestIsUser = true,
Action? retrying = null,
Action<string>? reasoningSummaryChanged = null)
Action<string>? reasoningSummaryChanged = null,
bool useStreaming = true)
{
this.httpClient = httpClient;
this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
responsesClient = CreateResponsesClient(httpClient, apiKey, requestInitiator);
this.model = model;
this.useStreaming = useStreaming;
this.enableThinking = enableThinking;
this.reasoningEffort = reasoningEffort;
this.reconnectionAttempts = Math.Max(0, reconnectionAttempts);
@@ -87,20 +100,50 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
httpClient.Dispose();
}
internal static LiteLlmResponsesChatClient Create(
AgentOptions options,
string apiKey,
LiteLlmResponsesCompactionOptions? compactionOptions,
ILogger? logger,
bool firstRequestIsUser,
Action? retrying = null,
Action<string>? reasoningSummaryChanged = null)
{
return new LiteLlmResponsesChatClient(
new Uri(options.Endpoint),
apiKey,
options.Model,
options.EnableThinking,
options.ReasoningEffort switch
{
ReasoningEffortOption.None => "none",
ReasoningEffortOption.Low => "low",
ReasoningEffortOption.High => "high",
ReasoningEffortOption.ExtraHigh => "xhigh",
_ => "medium"
},
options.ReconnectionAttempts,
options.ReconnectionDelay,
compactionOptions,
logger,
firstRequestIsUser,
retrying,
reasoningSummaryChanged,
options.UseStreaming);
}
public async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
var payload = await CreateCompactedPayloadAsync(messages.ToList(), options, cancellationToken).ConfigureAwait(false);
var responseJson = await PostWithRetryAsync(payload, cancellationToken).ConfigureAwait(false);
var response = ParseResponseJson(responseJson, out var reasoningSummaries);
ReportVisibleReasoningSummaries(reasoningSummaries);
LogResponseDiagnostics(responseJson, response);
ThrowIfResponseHasNoContent(responseJson, response);
LogResponseUsage(response.Usage);
return response;
var result = await PostWithRetryAsync(payload, cancellationToken).ConfigureAwait(false);
ReportVisibleReasoningSummaries(result.ReasoningSummaries);
LogResponseDiagnostics(result.Response);
ThrowIfResponseHasNoContent(result.Response);
LogResponseUsage(result.Response.Usage);
return result.Response;
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
@@ -127,79 +170,14 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
: null;
}
public static ChatResponse ParseResponseJson(string responseJson)
private static IReadOnlyList<string> ExtractVisibleReasoningSummaries(ChatResponse response)
{
return ParseResponseJson(responseJson, out _);
}
private static ChatResponse ParseResponseJson(
string responseJson,
out IReadOnlyList<string> reasoningSummaries)
{
using var document = JsonDocument.Parse(responseJson);
var root = document.RootElement;
var contents = new List<AIContent>();
reasoningSummaries = ExtractVisibleReasoningSummaries(root);
if (root.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array)
{
foreach (var item in output.EnumerateArray())
{
var type = GetString(item, "type");
if (type == "message")
{
AddMessageContent(contents, item);
}
else if (type == "function_call")
{
contents.Add(new FunctionCallContent(
GetRequiredString(item, "call_id"),
GetRequiredString(item, "name"),
ParseArguments(GetString(item, "arguments"))));
}
}
}
var message = new ChatMessage
{
Role = ChatRole.Assistant,
Contents = contents
};
return new ChatResponse(message)
{
ResponseId = GetString(root, "id"),
ModelId = GetString(root, "model"),
CreatedAt = GetUnixTimestamp(root, "created_at"),
Usage = ParseUsage(root),
RawRepresentation = responseJson
};
}
internal static IReadOnlyList<string> ExtractVisibleReasoningSummaries(string responseJson)
{
using var document = JsonDocument.Parse(responseJson);
return ExtractVisibleReasoningSummaries(document.RootElement);
}
private static IReadOnlyList<string> ExtractVisibleReasoningSummaries(JsonElement root)
{
var summaries = new List<string>();
if (!root.TryGetProperty("output", out var output) || output.ValueKind != JsonValueKind.Array)
{
return summaries;
}
foreach (var item in output.EnumerateArray())
{
if (GetString(item, "type") == "reasoning")
{
AddVisibleReasoningSummaryText(summaries, item);
}
}
return summaries;
return response.Messages
.SelectMany(message => message.Contents)
.OfType<TextReasoningContent>()
.Select(content => content.Text)
.Where(text => !string.IsNullOrWhiteSpace(text))
.ToArray();
}
private void ReportVisibleReasoningSummaries(IReadOnlyList<string> summaries)
@@ -407,7 +385,9 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
return payload;
}
private async Task<string> PostWithRetryAsync(JsonObject payload, CancellationToken cancellationToken)
private async Task<ResponsesChatResult> PostWithRetryAsync(
JsonObject payload,
CancellationToken cancellationToken)
{
var payloadJson = payload.ToJsonString(JsonOptions);
Exception? lastException = null;
@@ -418,26 +398,36 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
{
var initiator = NextInitiator();
LogRequestDiagnostics(payload, initiator, attempt);
using var request = CreateJsonRequest(
"responses",
payloadJson,
initiator);
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
var createOptions = ModelReaderWriter.Read<CreateResponseOptions>(
BinaryData.FromString(payloadJson),
ModelReaderWriterOptions.Json)
?? throw new InvalidOperationException("Unable to create OpenAI Responses request options.");
createOptions.StreamingEnabled = useStreaming;
var previousInitiator = requestInitiator.Value;
requestInitiator.Value = initiator;
try
{
return responseJson;
return useStreaming
? await GetStreamingResponseAsync(createOptions, cancellationToken).ConfigureAwait(false)
: CreateNonStreamingResult((await responsesClient
.CreateResponseAsync(createOptions, cancellationToken)
.ConfigureAwait(false))
.Value
.AsChatResponse(createOptions));
}
var exception = new InvalidOperationException(
$"LiteLLM Responses request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {responseJson}");
if (!IsRetryableStatusCode((int)response.StatusCode) || attempt == reconnectionAttempts)
finally
{
throw exception;
requestInitiator.Value = previousInitiator;
}
lastException = exception;
}
catch (ClientResultException exception)
when (!IsRetryableStatusCode(exception.Status) || attempt == reconnectionAttempts)
{
throw new InvalidOperationException(
$"LiteLLM Responses request failed with {exception.Status}: {exception.Message}",
exception);
}
catch (Exception exception) when (IsRetryableException(exception) && attempt < reconnectionAttempts)
{
@@ -451,6 +441,48 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
throw lastException ?? new InvalidOperationException("LiteLLM Responses request failed.");
}
private async Task<ResponsesChatResult> GetStreamingResponseAsync(
CreateResponseOptions createOptions,
CancellationToken cancellationToken)
{
var updates = new List<ChatResponseUpdate>();
var reasoningSummaries = new Dictionary<(string ItemId, int SummaryIndex), StringBuilder>();
await foreach (var update in responsesClient
.CreateResponseStreamingAsync(createOptions, cancellationToken)
.AsChatResponseUpdatesAsync(createOptions, cancellationToken)
.ConfigureAwait(false))
{
updates.Add(update);
var reasoningUpdate = update.RawRepresentation
as StreamingResponseReasoningSummaryTextDeltaUpdate;
if (reasoningUpdate is null)
{
continue;
}
var key = (reasoningUpdate.ItemId, reasoningUpdate.SummaryIndex);
if (!reasoningSummaries.TryGetValue(key, out var summary))
{
summary = new StringBuilder();
reasoningSummaries[key] = summary;
}
summary.Append(reasoningUpdate.Delta);
}
return new ResponsesChatResult(
updates.ToChatResponse(),
reasoningSummaries.Values
.Select(summary => summary.ToString())
.Where(summary => !string.IsNullOrWhiteSpace(summary))
.ToArray());
}
private static ResponsesChatResult CreateNonStreamingResult(ChatResponse response)
{
return new ResponsesChatResult(response, ExtractVisibleReasoningSummaries(response));
}
private HttpRequestMessage CreateJsonRequest(
string requestUri,
string payloadJson,
@@ -489,7 +521,9 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
private static bool IsRetryableException(Exception exception)
{
return exception is HttpRequestException or TaskCanceledException;
return exception is ClientResultException clientException
&& IsRetryableStatusCode(clientException.Status)
|| exception is HttpRequestException or TaskCanceledException;
}
private void LogContextWindow(int estimatedTokens, bool compacted, string source)
@@ -561,18 +595,18 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
instructionsPreview);
}
private void LogResponseDiagnostics(string responseJson, ChatResponse response)
private void LogResponseDiagnostics(ChatResponse response)
{
if (logger is null)
{
return;
}
var outputTypes = GetOutputItemTypes(responseJson);
var parsedTypes = response.Messages
.SelectMany(message => message.Contents)
.Select(content => content.GetType().Name)
.ToArray();
var outputTypes = GetOutputItemTypes(response);
var functionCalls = response.Messages
.SelectMany(message => message.Contents)
.OfType<FunctionCallContent>()
@@ -590,40 +624,29 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
if (parsedTypes.Length == 0)
{
logger.LogWarning(
"LiteLLM Responses response contained no parseable message text or function calls. Raw response preview: {ResponsePreview}",
Truncate(responseJson, maxLength: 6000));
"LiteLLM Responses response contained no parseable message text or function calls.");
}
}
private static void ThrowIfResponseHasNoContent(string responseJson, ChatResponse response)
private static void ThrowIfResponseHasNoContent(ChatResponse response)
{
if (response.Messages.SelectMany(message => message.Contents).Any())
{
return;
}
var outputTypes = GetOutputItemTypes(responseJson);
var outputTypes = GetOutputItemTypes(response);
throw new InvalidOperationException(
"LiteLLM Responses returned no parseable message text or function calls. " +
$"ResponseId={response.ResponseId ?? "<none>"}, outputTypes=[{string.Join(", ", outputTypes)}].");
}
private static string[] GetOutputItemTypes(string responseJson)
private static string[] GetOutputItemTypes(ChatResponse response)
{
try
{
using var document = JsonDocument.Parse(responseJson);
return document.RootElement.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array
? output
.EnumerateArray()
.Select(item => GetString(item, "type") ?? item.ValueKind.ToString())
.ToArray()
: [];
}
catch (JsonException)
{
return ["<invalid-json>"];
}
return response.Messages
.SelectMany(message => message.Contents)
.Select(content => content.RawRepresentation?.GetType().Name ?? content.GetType().Name)
.ToArray();
}
private static string Truncate(string value, int maxLength)
@@ -649,10 +672,19 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
if (!string.IsNullOrWhiteSpace(text))
{
var isAssistant = role == ChatRole.Assistant.Value;
input.Add(new JsonObject
{
["role"] = role == ChatRole.Assistant.Value ? "assistant" : "user",
["content"] = text
["type"] = "message",
["role"] = isAssistant ? "assistant" : "user",
["content"] = new JsonArray
{
new JsonObject
{
["type"] = isAssistant ? "output_text" : "input_text",
["text"] = text
}
}
});
}
@@ -665,7 +697,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
["type"] = "function_call",
["call_id"] = call.CallId,
["name"] = call.Name,
["arguments"] = JsonSerializer.Serialize(call.Arguments, JsonOptions)
["arguments"] = SerializeFunctionArguments(call)
});
}
else if (content is FunctionResultContent result)
@@ -702,110 +734,15 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
return result;
}
private static void AddMessageContent(List<AIContent> contents, JsonElement item)
private static string SerializeFunctionArguments(FunctionCallContent call)
{
if (!item.TryGetProperty("content", out var messageContent) || messageContent.ValueKind != JsonValueKind.Array)
if (call.RawRepresentation is FunctionCallResponseItem responseItem
&& responseItem.FunctionArguments is not null)
{
return;
return responseItem.FunctionArguments.ToString();
}
foreach (var content in messageContent.EnumerateArray())
{
if (GetString(content, "type") == "output_text")
{
var text = GetString(content, "text");
if (!string.IsNullOrEmpty(text))
{
contents.Add(new TextContent(text));
}
}
}
}
private static void AddVisibleReasoningSummaryText(List<string> summaries, JsonElement item)
{
AddVisibleReasoningSummaryText(summaries, item, "summary");
AddVisibleReasoningSummaryText(summaries, item, "content");
}
private static void AddVisibleReasoningSummaryText(
List<string> summaries,
JsonElement item,
string propertyName)
{
if (!item.TryGetProperty(propertyName, out var property))
{
return;
}
if (property.ValueKind == JsonValueKind.String)
{
AddNonEmpty(summaries, property.GetString());
return;
}
if (property.ValueKind != JsonValueKind.Array)
{
return;
}
foreach (var summaryItem in property.EnumerateArray())
{
if (summaryItem.ValueKind == JsonValueKind.String)
{
AddNonEmpty(summaries, summaryItem.GetString());
}
else if (summaryItem.ValueKind == JsonValueKind.Object)
{
if (propertyName == "content" && !IsSummaryContent(summaryItem))
{
continue;
}
AddNonEmpty(summaries, GetString(summaryItem, "text"));
}
}
}
private static bool IsSummaryContent(JsonElement item)
{
var type = GetString(item, "type");
return !string.IsNullOrWhiteSpace(type)
&& type.Contains("summary", StringComparison.OrdinalIgnoreCase);
}
private static void AddNonEmpty(List<string> values, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
values.Add(value.Trim());
}
}
private static Dictionary<string, object?> ParseArguments(string? arguments)
{
if (string.IsNullOrWhiteSpace(arguments))
{
return [];
}
using var document = JsonDocument.Parse(arguments);
return document.RootElement.EnumerateObject()
.ToDictionary(property => property.Name, property => ConvertJsonValue(property.Value));
}
private static object? ConvertJsonValue(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number when element.TryGetInt64(out var integer) => integer,
JsonValueKind.Number when element.TryGetDouble(out var number) => number,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => JsonSerializer.Deserialize<object>(element.GetRawText(), JsonOptions)
};
return JsonSerializer.Serialize(call.Arguments, JsonOptions);
}
private static string ResultToString(object? result)
@@ -818,57 +755,12 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
};
}
private static UsageDetails? ParseUsage(JsonElement root)
{
if (!root.TryGetProperty("usage", out var usage) || usage.ValueKind != JsonValueKind.Object)
{
return null;
}
var inputTokens = GetInt(usage, "input_tokens") ?? GetInt(usage, "prompt_tokens");
var outputTokens = GetInt(usage, "output_tokens") ?? GetInt(usage, "completion_tokens");
var totalTokens = GetInt(usage, "total_tokens");
return new UsageDetails
{
InputTokenCount = inputTokens,
OutputTokenCount = outputTokens,
TotalTokenCount = totalTokens
};
}
private static int EstimateTokens(JsonObject payload)
{
var json = payload.ToJsonString(JsonOptions);
return Math.Max(1, (int)Math.Ceiling(json.Length / 4.0));
}
private static int? GetInt(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out var property) && property.TryGetInt32(out var value)
? value
: null;
}
private static string GetRequiredString(JsonElement element, string propertyName)
{
return GetString(element, propertyName)
?? throw new InvalidOperationException($"LiteLLM response item did not include '{propertyName}'.");
}
private static string? GetString(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static DateTimeOffset? GetUnixTimestamp(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out var property) && property.TryGetInt64(out var value)
? DateTimeOffset.FromUnixTimeSeconds(value)
: null;
}
private static Uri NormalizeEndpoint(Uri endpoint)
{
var value = endpoint.ToString().TrimEnd('/');
@@ -884,5 +776,60 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
{
return value.TrimStart('/');
}
private static ResponsesClient CreateResponsesClient(
HttpClient httpClient,
string apiKey,
AsyncLocal<string?> requestInitiator)
{
var options = new OpenAIClientOptions
{
Endpoint = httpClient.BaseAddress
?? throw new InvalidOperationException("LiteLLM HTTP client requires a base address."),
Transport = new HttpClientPipelineTransport(httpClient),
RetryPolicy = new ClientRetryPolicy(0)
};
options.AddPolicy(
new InitiatorHeaderPolicy(requestInitiator),
PipelinePosition.PerCall);
return new ResponsesClient(
new ApiKeyCredential(apiKey),
options);
}
private sealed record ResponsesChatResult(
ChatResponse Response,
IReadOnlyList<string> ReasoningSummaries);
private sealed class InitiatorHeaderPolicy(AsyncLocal<string?> requestInitiator) : PipelinePolicy
{
public override void Process(
PipelineMessage message,
IReadOnlyList<PipelinePolicy> pipeline,
int currentIndex)
{
SetHeader(message);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(
PipelineMessage message,
IReadOnlyList<PipelinePolicy> pipeline,
int currentIndex)
{
SetHeader(message);
return ProcessNextAsync(message, pipeline, currentIndex);
}
private void SetHeader(PipelineMessage message)
{
if (!string.IsNullOrWhiteSpace(requestInitiator.Value))
{
message.Request.Headers.Set("X-Initiator", requestInitiator.Value);
}
}
}
}
#pragma warning restore OPENAI001
#pragma warning restore MAAI001
@@ -63,32 +63,24 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
meetingWorkflowEngine);
var tools = CreateTools(meetingTools);
var instructions = await instructionBuilder.BuildAsync(artifacts, options, cancellationToken);
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
new Uri(agentOptions.Endpoint),
using var compactionSummaryClient = LiteLlmResponsesChatClient.Create(
agentOptions,
key,
agentOptions.Model,
agentOptions.EnableThinking,
ToReasoningEffortValue(agentOptions.ReasoningEffort),
agentOptions.ReconnectionAttempts,
agentOptions.ReconnectionDelay,
compactionOptions: null,
logger,
firstRequestIsUser: false);
var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient);
using var chatClient = new LiteLlmResponsesChatClient(
new Uri(agentOptions.Endpoint),
using var chatClient = LiteLlmResponsesChatClient.Create(
agentOptions,
key,
agentOptions.Model,
agentOptions.EnableThinking,
ToReasoningEffortValue(agentOptions.ReasoningEffort),
agentOptions.ReconnectionAttempts,
agentOptions.ReconnectionDelay,
compactionOptions,
logger,
firstRequestIsUser: false);
var agent = chatClient
.AsBuilder()
.UseFunctionInvocation()
.UseFunctionInvocation(
loggerFactory,
client => client.FunctionInvoker = FunctionInvocationGuard.InvokeAsync)
.Build()
.AsAIAgent(
instructions,
@@ -312,18 +304,6 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
return new PipelineCompactionStrategy(strategies);
}
private static string ToReasoningEffortValue(ReasoningEffortOption effort)
{
return effort switch
{
ReasoningEffortOption.None => "none",
ReasoningEffortOption.Low => "low",
ReasoningEffortOption.High => "high",
ReasoningEffortOption.ExtraHigh => "xhigh",
_ => "medium"
};
}
private static ReasoningEffort ToReasoningEffort(ReasoningEffortOption effort)
{
return effort switch