Files
meeting-assistant/MeetingAssistant.Tests/LiteLlmResponsesChatClientTests.cs
T
2026-05-20 02:06:16 +02:00

336 lines
11 KiB
C#

using MeetingAssistant.Summary;
using Microsoft.Extensions.AI;
using System.Net;
namespace MeetingAssistant.Tests;
public sealed class LiteLlmResponsesChatClientTests
{
[Fact]
public void ParserIgnoresReasoningItemsWithNullStatusAndReadsText()
{
const string json = """
{
"id": "resp_test",
"created_at": 1779147100,
"model": "gpt-5.5-2026-04-23",
"output": [
{
"type": "reasoning",
"summary": [],
"status": null
},
{
"type": "message",
"role": "assistant",
"status": "completed",
"content": [
{
"type": "output_text",
"text": "OK"
}
]
}
]
}
""";
var response = LiteLlmResponsesChatClient.ParseResponseJson(json);
Assert.Equal("OK", response.Text);
Assert.Equal("resp_test", response.ResponseId);
Assert.Equal("gpt-5.5-2026-04-23", response.ModelId);
}
[Fact]
public void ParserReadsFunctionCalls()
{
const string json = """
{
"output": [
{
"type": "function_call",
"call_id": "call_1",
"name": "write_summary",
"arguments": "{\"markdown\":\"# Summary\\nDone\"}",
"status": "completed"
}
]
}
""";
var response = LiteLlmResponsesChatClient.ParseResponseJson(json);
var call = Assert.IsType<FunctionCallContent>(Assert.Single(response.Messages[0].Contents));
Assert.Equal("call_1", call.CallId);
Assert.Equal("write_summary", call.Name);
Assert.NotNull(call.Arguments);
Assert.Equal("# Summary\nDone", call.Arguments["markdown"]);
}
[Fact]
public void ParserReadsUsage()
{
const string json = """
{
"usage": {
"input_tokens": 123,
"output_tokens": 45,
"total_tokens": 168
},
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "OK"
}
]
}
]
}
""";
var response = LiteLlmResponsesChatClient.ParseResponseJson(json);
Assert.NotNull(response.Usage);
Assert.Equal(123, response.Usage.InputTokenCount);
Assert.Equal(45, response.Usage.OutputTokenCount);
Assert.Equal(168, response.Usage.TotalTokenCount);
}
[Fact]
public async Task ClientRetriesTransientServerFailure()
{
var handler = new SequencedHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("Internal Server Error")
},
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "Done."
}
]
}
]
}
""")
});
using var client = CreateClient(handler, reconnectionAttempts: 1);
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
Assert.Equal("Done.", response.Text);
Assert.Equal(2, handler.RequestCount);
}
[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 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 new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "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]);
}
[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 new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "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)
{
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);
}
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; } = [];
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));
return handler(request);
}
}
}