Public Access
336 lines
11 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|