Public Access
fix: support LiteLLM Responses streaming
PR and Push Build/Test / build-and-test (push) Successful in 14m17s
PR and Push Build/Test / build-and-test (push) Successful in 14m17s
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user