Archive summary refinements and profile switching
PR and Push Build/Test / build-and-test (push) Successful in 6m37s

This commit is contained in:
2026-05-27 23:11:21 +02:00
parent c12febe029
commit 7777b349a5
37 changed files with 3190 additions and 121 deletions
@@ -21,6 +21,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
private readonly TimeSpan reconnectionDelay;
private readonly LiteLlmResponsesCompactionOptions? compactionOptions;
private readonly ILogger? logger;
private int responsesRequestCount;
public LiteLlmResponsesChatClient(
Uri endpoint,
@@ -31,7 +32,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
int reconnectionAttempts,
TimeSpan reconnectionDelay,
LiteLlmResponsesCompactionOptions? compactionOptions = null,
ILogger? logger = null)
ILogger? logger = null,
bool firstRequestIsUser = true)
: this(
new HttpClient { BaseAddress = NormalizeEndpoint(endpoint) },
apiKey,
@@ -41,7 +43,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
reconnectionAttempts,
reconnectionDelay,
compactionOptions,
logger)
logger,
firstRequestIsUser)
{
}
@@ -54,7 +57,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
int reconnectionAttempts,
TimeSpan reconnectionDelay,
LiteLlmResponsesCompactionOptions? compactionOptions = null,
ILogger? logger = null)
ILogger? logger = null,
bool firstRequestIsUser = true)
{
this.httpClient = httpClient;
this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
@@ -65,6 +69,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
this.reconnectionDelay = reconnectionDelay > TimeSpan.Zero ? reconnectionDelay : TimeSpan.Zero;
this.compactionOptions = compactionOptions;
this.logger = logger;
responsesRequestCount = firstRequestIsUser ? 0 : 1;
}
public void Dispose()
@@ -81,6 +86,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
var responseJson = await PostWithRetryAsync(payload, cancellationToken).ConfigureAwait(false);
var response = ParseResponseJson(responseJson);
LogResponseDiagnostics(responseJson, response);
ThrowIfResponseHasNoContent(responseJson, response);
LogResponseUsage(response.Usage);
return response;
}
@@ -225,12 +232,11 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
}
};
using var content = new StringContent(request.ToJsonString(JsonOptions), Encoding.UTF8, "application/json");
using var response = await httpClient.PostAsync(
NormalizeRelativePath(compactionOptions.CompactPath),
content,
cancellationToken)
.ConfigureAwait(false);
using var requestMessage = CreateJsonRequest(
NormalizeRelativePath(compactionOptions.CompactPath),
request.ToJsonString(JsonOptions),
"agent");
using var response = await httpClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false);
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
@@ -319,6 +325,11 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
};
}
if (options?.MaxOutputTokens is > 0)
{
payload["max_output_tokens"] = options.MaxOutputTokens.Value;
}
var tools = CreateTools(options?.Tools);
if (tools.Count > 0)
{
@@ -338,8 +349,13 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
{
try
{
using var content = new StringContent(payloadJson, Encoding.UTF8, "application/json");
using var response = await httpClient.PostAsync("responses", content, cancellationToken).ConfigureAwait(false);
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)
@@ -367,6 +383,28 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
throw lastException ?? new InvalidOperationException("LiteLLM Responses request failed.");
}
private HttpRequestMessage CreateJsonRequest(
string requestUri,
string payloadJson,
string? initiator = null)
{
var request = new HttpRequestMessage(HttpMethod.Post, requestUri)
{
Content = new StringContent(payloadJson, Encoding.UTF8, "application/json")
};
if (!string.IsNullOrWhiteSpace(initiator))
{
request.Headers.TryAddWithoutValidation("X-Initiator", initiator);
}
return request;
}
private string NextInitiator()
{
return Interlocked.Increment(ref responsesRequestCount) == 1 ? "user" : "agent";
}
private async Task DelayBeforeRetryAsync(CancellationToken cancellationToken)
{
if (reconnectionDelay > TimeSpan.Zero)
@@ -423,6 +461,110 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
contextWindow);
}
private void LogRequestDiagnostics(JsonObject payload, string initiator, int attempt)
{
if (logger is null)
{
return;
}
var inputCount = payload.TryGetPropertyValue("input", out var input) && input is JsonArray inputArray
? inputArray.Count
: 0;
var tools = payload.TryGetPropertyValue("tools", out var toolsNode) && toolsNode is JsonArray toolsArray
? toolsArray
: [];
var toolNames = tools
.OfType<JsonObject>()
.Select(tool => tool.TryGetPropertyValue("name", out var name) ? name?.GetValue<string>() : null)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToArray();
var instructionsPreview = payload.TryGetPropertyValue("instructions", out var instructionsNode)
? Truncate(instructionsNode?.GetValue<string>() ?? "", maxLength: 500)
: "";
logger.LogInformation(
"LiteLLM Responses request: initiator={Initiator}, attempt={Attempt}, inputItems={InputItems}, tools={ToolCount}, toolNames={ToolNames}, instructionsPreview={InstructionsPreview}",
initiator,
attempt + 1,
inputCount,
toolNames.Length,
string.Join(", ", toolNames),
instructionsPreview);
}
private void LogResponseDiagnostics(string responseJson, 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 functionCalls = response.Messages
.SelectMany(message => message.Contents)
.OfType<FunctionCallContent>()
.Select(call => call.Name)
.ToArray();
logger.LogInformation(
"LiteLLM Responses response: responseId={ResponseId}, outputTypes={OutputTypes}, parsedContents={ParsedContents}, functionCalls={FunctionCalls}, textLength={TextLength}",
response.ResponseId,
string.Join(", ", outputTypes),
string.Join(", ", parsedTypes),
string.Join(", ", functionCalls),
response.Text?.Length ?? 0);
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));
}
}
private static void ThrowIfResponseHasNoContent(string responseJson, ChatResponse response)
{
if (response.Messages.SelectMany(message => message.Contents).Any())
{
return;
}
var outputTypes = GetOutputItemTypes(responseJson);
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)
{
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>"];
}
}
private static string Truncate(string value, int maxLength)
{
return value.Length <= maxLength
? value
: value[..maxLength] + "...";
}
private static void AddInputItem(JsonArray input, StringBuilder instructions, ChatMessage message)
{
var role = message.Role.Value;