Files
meeting-assistant/MeetingAssistant.Tests/LiteLlmResponsesChatClientTests.cs
T
codex e192ae7cd8
PR and Push Build/Test / build-and-test (push) Successful in 14m17s
fix: support LiteLLM Responses streaming
2026-07-27 14:44:32 +02:00

511 lines
20 KiB
C#

using MeetingAssistant.Summary;
using Microsoft.Extensions.AI;
using System.Net;
using System.Text;
using System.Text.Json;
namespace MeetingAssistant.Tests;
public sealed class LiteLlmResponsesChatClientTests
{
[Fact]
public async Task ClientAssemblesStreamedTextResponseWithMetadataAndUsage()
{
var handler = new SequencedHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""
data: {"type":"response.created","response":{"id":"resp_stream","created_at":1779147100,"model":"gpt-5.5","object":"response","output":[],"parallel_tool_calls":true,"status":"in_progress","store":false},"sequence_number":0}
data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_stream","type":"message","status":"in_progress","content":[],"role":"assistant"},"sequence_number":1}
data: {"type":"response.content_part.added","item_id":"msg_stream","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"text":""},"sequence_number":2}
data: {"type":"response.output_text.delta","item_id":"msg_stream","output_index":0,"content_index":0,"delta":"Streamed OK","sequence_number":3}
data: {"type":"response.output_text.done","item_id":"msg_stream","output_index":0,"content_index":0,"text":"Streamed OK","sequence_number":4}
data: {"type":"response.content_part.done","item_id":"msg_stream","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"text":"Streamed OK"},"sequence_number":5}
data: {"type":"response.output_item.done","output_index":0,"item":{"id":"msg_stream","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Streamed OK"}],"role":"assistant"},"sequence_number":6}
data: {"type":"response.completed","response":{"id":"resp_stream","created_at":1779147100,"model":"gpt-5.5","object":"response","output":[],"parallel_tool_calls":true,"status":"completed","store":false,"usage":{"input_tokens":12,"output_tokens":3,"total_tokens":15}},"sequence_number":7}
data: [DONE]
""",
Encoding.UTF8,
"text/event-stream")
});
using var client = CreateClient(handler, reconnectionAttempts: 0);
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "reply")]);
Assert.Equal("Streamed OK", response.Text);
Assert.Equal("resp_stream", response.ResponseId);
Assert.Equal("gpt-5.5", response.ModelId);
Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1779147100), response.CreatedAt);
Assert.NotNull(response.Usage);
Assert.Equal(12, response.Usage.InputTokenCount);
Assert.Equal(3, response.Usage.OutputTokenCount);
Assert.Equal(15, response.Usage.TotalTokenCount);
}
[Fact]
public async Task ClientAssemblesStreamedFunctionCall()
{
var handler = new SequencedHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""
data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_stream","name":"write_summary","arguments":"{\"markdown\":\"# Summary\\nDone\"}","status":"completed"}}
data: {"type":"response.completed","response":{"id":"resp_stream","model":"gpt-5.5","output":[]}}
data: [DONE]
""",
Encoding.UTF8,
"text/event-stream")
});
using var client = CreateClient(handler, reconnectionAttempts: 0);
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
var call = Assert.IsType<FunctionCallContent>(Assert.Single(response.Messages[0].Contents));
Assert.Equal("call_stream", call.CallId);
Assert.Equal("write_summary", call.Name);
Assert.NotNull(call.Arguments);
Assert.Equal("# Summary\nDone", call.Arguments["markdown"]?.ToString());
}
[Fact]
public async Task ClientUsesNonStreamingResponsesWhenConfigured()
{
var handler = new RecordingHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""
{
"id": "resp_nonstream",
"created_at": 1779147100,
"model": "gpt-5.5",
"object": "response",
"output": [
{
"id": "msg_nonstream",
"type": "message",
"status": "completed",
"content": [
{
"type": "output_text",
"annotations": [],
"text": "Non-streamed OK"
}
],
"role": "assistant"
}
],
"parallel_tool_calls": true,
"status": "completed",
"store": false,
"usage": {
"input_tokens": 12,
"output_tokens": 4,
"total_tokens": 16
}
}
""",
Encoding.UTF8,
"application/json")
});
using var client = CreateClient(handler, reconnectionAttempts: 0, useStreaming: false);
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "reply")]);
Assert.Equal("Non-streamed OK", response.Text);
Assert.Contains("\"stream\":false", Assert.Single(handler.RequestBodies));
}
[Fact]
public async Task ClientSendsChatMessagesAsResponsesMessageItems()
{
var handler = new RecordingHttpMessageHandler(_ => CreateStreamedTextResponse("Done."));
using var client = CreateClient(handler, reconnectionAttempts: 0);
await client.GetResponseAsync(
[
new ChatMessage(ChatRole.User, "write summary"),
new ChatMessage(ChatRole.Assistant, "I will inspect the meeting.")
]);
using var request = JsonDocument.Parse(Assert.Single(handler.RequestBodies));
var inputItems = request.RootElement.GetProperty("input").EnumerateArray().ToArray();
Assert.Equal(2, inputItems.Length);
Assert.All(inputItems, item => Assert.Equal("message", item.GetProperty("type").GetString()));
Assert.Equal("user", inputItems[0].GetProperty("role").GetString());
Assert.Equal(
"input_text",
Assert.Single(inputItems[0].GetProperty("content").EnumerateArray())
.GetProperty("type")
.GetString());
Assert.Equal("assistant", inputItems[1].GetProperty("role").GetString());
Assert.Equal(
"output_text",
Assert.Single(inputItems[1].GetProperty("content").EnumerateArray())
.GetProperty("type")
.GetString());
Assert.DoesNotContain("\"type\":\"unknown\"", handler.RequestBodies[0]);
}
[Fact]
public async Task AgentLoopReturnsMalformedFunctionArgumentsWithoutInvokingTool()
{
var responses = new Queue<HttpResponseMessage>(
[
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""
data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_invalid","name":"write_summary","arguments":"{not-json","status":"completed"}}
data: {"type":"response.completed","response":{"id":"resp_invalid","model":"gpt-5.5","output":[]}}
data: [DONE]
""",
Encoding.UTF8,
"text/event-stream")
},
CreateStreamedTextResponse("Recovered.")
]);
var handler = new RecordingHttpMessageHandler(_ => responses.Dequeue());
var toolInvoked = false;
var tool = AIFunctionFactory.Create(
(string markdown) =>
{
toolInvoked = true;
return markdown;
},
"write_summary",
"Writes a summary.");
using var innerClient = CreateClient(handler, reconnectionAttempts: 0);
using var functionClient = innerClient
.AsBuilder()
.UseFunctionInvocation(
loggerFactory: null,
client => client.FunctionInvoker = FunctionInvocationGuard.InvokeAsync)
.Build();
var response = await functionClient.GetResponseAsync(
[new ChatMessage(ChatRole.User, "write summary")],
new ChatOptions { Tools = [tool] });
Assert.False(toolInvoked);
Assert.Equal("Recovered.", response.Text);
Assert.Equal(2, handler.RequestBodies.Count);
Assert.Contains("invalid_tool_arguments", handler.RequestBodies[1]);
Assert.Contains("call_invalid", handler.RequestBodies[1]);
}
[Fact]
public async Task ClientReportsVisibleReasoningSummariesSeparatelyFromResponseText()
{
var handler = new SequencedHttpMessageHandler(
CreateStreamedTextResponse(
"Done.",
"Checked the configured rules file.",
"Prepared a targeted update."));
var reasoningSummaries = new List<string>();
using var client = CreateClient(
handler,
reconnectionAttempts: 0,
reasoningSummaryChanged: reasoningSummaries.Add);
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "update rules")]);
Assert.Equal("Done.", response.Text);
Assert.Equal(
[
"Checked the configured rules file.",
"Prepared a targeted update."
],
reasoningSummaries);
}
[Fact]
public async Task ClientDoesNotFailResponseWhenReasoningSummaryCallbackFails()
{
var handler = new SequencedHttpMessageHandler(
CreateStreamedTextResponse("Done.", "Checked the configured rules file."));
using var client = CreateClient(
handler,
reconnectionAttempts: 0,
reasoningSummaryChanged: _ => throw new InvalidOperationException("UI failed"));
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "update rules")]);
Assert.Equal("Done.", response.Text);
}
[Fact]
public async Task ClientRetriesTransientServerFailure()
{
var handler = new SequencedHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("Internal Server Error")
},
CreateStreamedTextResponse("Done."));
var retryCount = 0;
using var client = CreateClient(handler, reconnectionAttempts: 1, retrying: () => retryCount++);
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
Assert.Equal("Done.", response.Text);
Assert.Equal(2, handler.RequestCount);
Assert.Equal(1, retryCount);
}
[Fact]
public async Task ClientDoesNotRetryNonTransientClientFailure()
{
var handler = new SequencedHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad request")
});
using var client = CreateClient(handler, reconnectionAttempts: 3);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]));
Assert.Contains("400", exception.Message);
Assert.Equal(1, handler.RequestCount);
}
[Fact]
public async Task ClientSendsUserInitiatorOnceThenAgentInitiator()
{
var handler = new RecordingHttpMessageHandler(_ => CreateStreamedTextResponse("Done."));
using var client = CreateClient(handler, reconnectionAttempts: 0);
await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
await client.GetResponseAsync([new ChatMessage(ChatRole.Tool, "tool result")]);
await client.GetResponseAsync([new ChatMessage(ChatRole.Assistant, "continue")]);
Assert.Equal(["user", "agent", "agent"], handler.RequestHeaders["X-Initiator"]);
}
[Fact]
public async Task ClientUsesResponsesCompactionEndpointWhenContextThresholdIsReached()
{
var handler = new RecordingHttpMessageHandler(request =>
{
if (request.RequestUri?.PathAndQuery == "/v1/responses/compact")
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"input": [
{
"role": "user",
"content": "compacted"
}
]
}
""")
};
}
return CreateStreamedTextResponse("Done.");
});
using var client = CreateClient(
handler,
reconnectionAttempts: 0,
new LiteLlmResponsesCompactionOptions
{
Enabled = true,
ContextWindowTokens = 100,
MaxOutputTokens = 10,
RemainingRatio = 0.10,
CompactPath = "responses/compact"
});
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, new string('x', 800))]);
Assert.Equal("Done.", response.Text);
Assert.Equal(["/v1/responses/compact", "/v1/responses"], handler.RequestPaths);
Assert.Contains("\"content\":\"compacted\"", handler.RequestBodies[1]);
Assert.Equal(["agent", "user"], handler.RequestHeaders["X-Initiator"]);
}
[Fact]
public async Task ClientFallsBackToAgentFrameworkCompactionWhenResponsesCompactionFails()
{
var handler = new RecordingHttpMessageHandler(request =>
{
if (request.RequestUri?.PathAndQuery == "/v1/responses/compact")
{
return new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent("missing")
};
}
return CreateStreamedTextResponse("Done.");
});
using var client = CreateClient(
handler,
reconnectionAttempts: 0,
new LiteLlmResponsesCompactionOptions
{
Enabled = true,
ContextWindowTokens = 100,
MaxOutputTokens = 10,
RemainingRatio = 0.10,
CompactPath = "responses/compact",
FallbackStrategy = OpenAiMeetingSummaryAgentPipeline.CreateFallbackCompactionStrategyForTests(
contextWindowTokens: 100,
maxOutputTokens: 10,
remainingRatio: 0.10,
summaryClient: null)
});
var response = await client.GetResponseAsync(
Enumerable.Range(0, 8)
.Select(index => new ChatMessage(ChatRole.User, $"turn {index} {new string('x', 200)}")));
Assert.Equal("Done.", response.Text);
Assert.Equal(["/v1/responses/compact", "/v1/responses"], handler.RequestPaths);
Assert.DoesNotContain("turn 0", handler.RequestBodies[1]);
}
private static LiteLlmResponsesChatClient CreateClient(
HttpMessageHandler handler,
int reconnectionAttempts,
LiteLlmResponsesCompactionOptions? compactionOptions = null,
Action? retrying = null,
Action<string>? reasoningSummaryChanged = null,
bool useStreaming = true)
{
return new LiteLlmResponsesChatClient(
new HttpClient(handler)
{
BaseAddress = new Uri("https://litellm.example/v1/")
},
"test-key",
"test-model",
enableThinking: false,
reasoningEffort: "none",
reconnectionAttempts,
TimeSpan.Zero,
compactionOptions,
retrying: retrying,
reasoningSummaryChanged: reasoningSummaryChanged,
useStreaming: useStreaming);
}
private static HttpResponseMessage CreateStreamedTextResponse(
string text,
params string[] reasoningSummaries)
{
var events = new List<string>();
for (var index = 0; index < reasoningSummaries.Length; index++)
{
events.Add("data: " + JsonSerializer.Serialize(new
{
type = "response.reasoning_summary_text.delta",
item_id = "reasoning_stream",
output_index = 0,
summary_index = index,
delta = reasoningSummaries[index]
}));
}
var outputIndex = reasoningSummaries.Length > 0 ? 1 : 0;
events.Add("data: " + JsonSerializer.Serialize(new
{
type = "response.output_text.delta",
item_id = "msg_stream",
output_index = outputIndex,
content_index = 0,
delta = text
}));
events.Add(
"""data: {"type":"response.completed","response":{"id":"resp_stream","created_at":1779147100,"model":"gpt-5.5","object":"response","output":[],"parallel_tool_calls":true,"status":"completed","store":false,"usage":{"input_tokens":12,"output_tokens":3,"total_tokens":15}}}""");
events.Add("data: [DONE]");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
string.Join($"{Environment.NewLine}{Environment.NewLine}", events)
+ Environment.NewLine
+ Environment.NewLine,
Encoding.UTF8,
"text/event-stream")
};
}
private sealed class SequencedHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> responses;
public SequencedHttpMessageHandler(params HttpResponseMessage[] responses)
{
this.responses = new Queue<HttpResponseMessage>(responses);
}
public int RequestCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestCount++;
return Task.FromResult(responses.Dequeue());
}
}
private sealed class RecordingHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> handler;
public RecordingHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
{
this.handler = handler;
}
public List<string> RequestPaths { get; } = [];
public List<string> RequestBodies { get; } = [];
public Dictionary<string, List<string>> RequestHeaders { get; } = new(StringComparer.OrdinalIgnoreCase);
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestPaths.Add(request.RequestUri?.PathAndQuery ?? string.Empty);
RequestBodies.Add(request.Content is null
? string.Empty
: await request.Content.ReadAsStringAsync(cancellationToken));
foreach (var header in request.Headers)
{
if (!RequestHeaders.TryGetValue(header.Key, out var values))
{
values = [];
RequestHeaders[header.Key] = values;
}
values.AddRange(header.Value);
}
return handler(request);
}
}
}