Public Access
Compare commits
4
Commits
6511a947e7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9547ae4c4 | ||
|
|
75250f6041 | ||
|
|
cd2446f620 | ||
|
|
e192ae7cd8 |
@@ -102,6 +102,23 @@ public sealed class AudioMixingTests
|
|||||||
Assert.Equal(2_000, BitConverter.ToInt16(chunks[0].Pcm));
|
Assert.Equal(2_000, BitConverter.ToInt16(chunks[0].Pcm));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CompositeAudioSourceKeepsSystemAudioWhileMicrophoneIsRecovering()
|
||||||
|
{
|
||||||
|
var microphone = new WaitingAudioSource();
|
||||||
|
var system = new FixedAudioSource(Pcm16(10_000));
|
||||||
|
var source = CreateSource(microphone, system);
|
||||||
|
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
await using var chunks = source
|
||||||
|
.CaptureAsync(new MeetingAssistantOptions(), cancellation.Token)
|
||||||
|
.GetAsyncEnumerator(cancellation.Token);
|
||||||
|
|
||||||
|
Assert.True(await chunks.MoveNextAsync());
|
||||||
|
Assert.Equal(10_000, BitConverter.ToInt16(chunks.Current.Pcm));
|
||||||
|
|
||||||
|
await cancellation.CancelAsync();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AdaptiveEchoCancellerReducesEchoFromMicrophoneSignal()
|
public void AdaptiveEchoCancellerReducesEchoFromMicrophoneSignal()
|
||||||
{
|
{
|
||||||
@@ -221,6 +238,16 @@ public sealed class AudioMixingTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class WaitingAudioSource : IMeetingAudioSource
|
||||||
|
{
|
||||||
|
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class OptionsCapturingAudioSource : IMeetingAudioSource
|
private sealed class OptionsCapturingAudioSource : IMeetingAudioSource
|
||||||
{
|
{
|
||||||
private readonly AudioChunk chunk;
|
private readonly AudioChunk chunk;
|
||||||
|
|||||||
@@ -1,82 +1,223 @@
|
|||||||
using MeetingAssistant.Summary;
|
using MeetingAssistant.Summary;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace MeetingAssistant.Tests;
|
namespace MeetingAssistant.Tests;
|
||||||
|
|
||||||
public sealed class LiteLlmResponsesChatClientTests
|
public sealed class LiteLlmResponsesChatClientTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParserIgnoresReasoningItemsWithNullStatusAndReadsText()
|
public async Task ClientAssemblesStreamedTextResponseWithMetadataAndUsage()
|
||||||
{
|
{
|
||||||
const string json = """
|
var handler = new SequencedHttpMessageHandler(
|
||||||
|
new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
{
|
{
|
||||||
"id": "resp_test",
|
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,
|
"created_at": 1779147100,
|
||||||
"model": "gpt-5.5-2026-04-23",
|
"model": "gpt-5.5",
|
||||||
|
"object": "response",
|
||||||
"output": [
|
"output": [
|
||||||
{
|
{
|
||||||
"type": "reasoning",
|
"id": "msg_nonstream",
|
||||||
"summary": [],
|
|
||||||
"status": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
"type": "output_text",
|
"type": "output_text",
|
||||||
"text": "OK"
|
"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 = LiteLlmResponsesChatClient.ParseResponseJson(json);
|
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "reply")]);
|
||||||
|
|
||||||
Assert.Equal("OK", response.Text);
|
Assert.Equal("Non-streamed OK", response.Text);
|
||||||
Assert.Equal("resp_test", response.ResponseId);
|
Assert.Contains("\"stream\":false", Assert.Single(handler.RequestBodies));
|
||||||
Assert.Equal("gpt-5.5-2026-04-23", response.ModelId);
|
}
|
||||||
|
|
||||||
|
[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]
|
[Fact]
|
||||||
public async Task ClientReportsVisibleReasoningSummariesSeparatelyFromResponseText()
|
public async Task ClientReportsVisibleReasoningSummariesSeparatelyFromResponseText()
|
||||||
{
|
{
|
||||||
var handler = new SequencedHttpMessageHandler(
|
var handler = new SequencedHttpMessageHandler(
|
||||||
new HttpResponseMessage(HttpStatusCode.OK)
|
CreateStreamedTextResponse(
|
||||||
{
|
"Done.",
|
||||||
Content = new StringContent("""
|
"Checked the configured rules file.",
|
||||||
{
|
"Prepared a targeted update."));
|
||||||
"output": [
|
|
||||||
{
|
|
||||||
"type": "reasoning",
|
|
||||||
"summary": [
|
|
||||||
{
|
|
||||||
"type": "summary_text",
|
|
||||||
"text": "Checked the configured rules file."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "summary_text",
|
|
||||||
"text": "Prepared a targeted update."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "output_text",
|
|
||||||
"text": "Done."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
});
|
|
||||||
var reasoningSummaries = new List<string>();
|
var reasoningSummaries = new List<string>();
|
||||||
using var client = CreateClient(
|
using var client = CreateClient(
|
||||||
handler,
|
handler,
|
||||||
@@ -98,33 +239,7 @@ public sealed class LiteLlmResponsesChatClientTests
|
|||||||
public async Task ClientDoesNotFailResponseWhenReasoningSummaryCallbackFails()
|
public async Task ClientDoesNotFailResponseWhenReasoningSummaryCallbackFails()
|
||||||
{
|
{
|
||||||
var handler = new SequencedHttpMessageHandler(
|
var handler = new SequencedHttpMessageHandler(
|
||||||
new HttpResponseMessage(HttpStatusCode.OK)
|
CreateStreamedTextResponse("Done.", "Checked the configured rules file."));
|
||||||
{
|
|
||||||
Content = new StringContent("""
|
|
||||||
{
|
|
||||||
"output": [
|
|
||||||
{
|
|
||||||
"type": "reasoning",
|
|
||||||
"summary": [
|
|
||||||
{
|
|
||||||
"type": "summary_text",
|
|
||||||
"text": "Checked the configured rules file."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "output_text",
|
|
||||||
"text": "Done."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
});
|
|
||||||
using var client = CreateClient(
|
using var client = CreateClient(
|
||||||
handler,
|
handler,
|
||||||
reconnectionAttempts: 0,
|
reconnectionAttempts: 0,
|
||||||
@@ -135,64 +250,6 @@ public sealed class LiteLlmResponsesChatClientTests
|
|||||||
Assert.Equal("Done.", response.Text);
|
Assert.Equal("Done.", response.Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
[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]
|
[Fact]
|
||||||
public async Task ClientRetriesTransientServerFailure()
|
public async Task ClientRetriesTransientServerFailure()
|
||||||
{
|
{
|
||||||
@@ -201,24 +258,7 @@ public sealed class LiteLlmResponsesChatClientTests
|
|||||||
{
|
{
|
||||||
Content = new StringContent("Internal Server Error")
|
Content = new StringContent("Internal Server Error")
|
||||||
},
|
},
|
||||||
new HttpResponseMessage(HttpStatusCode.OK)
|
CreateStreamedTextResponse("Done."));
|
||||||
{
|
|
||||||
Content = new StringContent("""
|
|
||||||
{
|
|
||||||
"output": [
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "output_text",
|
|
||||||
"text": "Done."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
});
|
|
||||||
var retryCount = 0;
|
var retryCount = 0;
|
||||||
using var client = CreateClient(handler, reconnectionAttempts: 1, retrying: () => retryCount++);
|
using var client = CreateClient(handler, reconnectionAttempts: 1, retrying: () => retryCount++);
|
||||||
|
|
||||||
@@ -249,24 +289,7 @@ public sealed class LiteLlmResponsesChatClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ClientSendsUserInitiatorOnceThenAgentInitiator()
|
public async Task ClientSendsUserInitiatorOnceThenAgentInitiator()
|
||||||
{
|
{
|
||||||
var handler = new RecordingHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
var handler = new RecordingHttpMessageHandler(_ => CreateStreamedTextResponse("Done."));
|
||||||
{
|
|
||||||
Content = new StringContent("""
|
|
||||||
{
|
|
||||||
"output": [
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "output_text",
|
|
||||||
"text": "Done."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
});
|
|
||||||
using var client = CreateClient(handler, reconnectionAttempts: 0);
|
using var client = CreateClient(handler, reconnectionAttempts: 0);
|
||||||
|
|
||||||
await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
|
await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
|
||||||
@@ -298,24 +321,7 @@ public sealed class LiteLlmResponsesChatClientTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
return CreateStreamedTextResponse("Done.");
|
||||||
{
|
|
||||||
Content = new StringContent("""
|
|
||||||
{
|
|
||||||
"output": [
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "output_text",
|
|
||||||
"text": "Done."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
using var client = CreateClient(
|
using var client = CreateClient(
|
||||||
handler,
|
handler,
|
||||||
@@ -350,24 +356,7 @@ public sealed class LiteLlmResponsesChatClientTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
return CreateStreamedTextResponse("Done.");
|
||||||
{
|
|
||||||
Content = new StringContent("""
|
|
||||||
{
|
|
||||||
"output": [
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "output_text",
|
|
||||||
"text": "Done."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
using var client = CreateClient(
|
using var client = CreateClient(
|
||||||
handler,
|
handler,
|
||||||
@@ -400,7 +389,8 @@ public sealed class LiteLlmResponsesChatClientTests
|
|||||||
int reconnectionAttempts,
|
int reconnectionAttempts,
|
||||||
LiteLlmResponsesCompactionOptions? compactionOptions = null,
|
LiteLlmResponsesCompactionOptions? compactionOptions = null,
|
||||||
Action? retrying = null,
|
Action? retrying = null,
|
||||||
Action<string>? reasoningSummaryChanged = null)
|
Action<string>? reasoningSummaryChanged = null,
|
||||||
|
bool useStreaming = true)
|
||||||
{
|
{
|
||||||
return new LiteLlmResponsesChatClient(
|
return new LiteLlmResponsesChatClient(
|
||||||
new HttpClient(handler)
|
new HttpClient(handler)
|
||||||
@@ -415,7 +405,49 @@ public sealed class LiteLlmResponsesChatClientTests
|
|||||||
TimeSpan.Zero,
|
TimeSpan.Zero,
|
||||||
compactionOptions,
|
compactionOptions,
|
||||||
retrying: retrying,
|
retrying: retrying,
|
||||||
reasoningSummaryChanged: reasoningSummaryChanged);
|
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 sealed class SequencedHttpMessageHandler : HttpMessageHandler
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using MeetingAssistant.Recording;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace MeetingAssistant.Tests;
|
||||||
|
|
||||||
|
public sealed class MicrophoneAudioSourceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task CaptureMovesToNewlyResolvedMicrophoneWhenCurrentCaptureFails()
|
||||||
|
{
|
||||||
|
var captureSources = new SequenceMicrophoneCaptureSourceFactory(
|
||||||
|
new FailingAfterChunkAudioSource(Pcm16(1_000)),
|
||||||
|
new ActiveAudioSource(Pcm16(2_000)));
|
||||||
|
var source = new MicrophoneAudioSource(
|
||||||
|
captureSources,
|
||||||
|
NullLogger<MicrophoneAudioSource>.Instance,
|
||||||
|
TimeSpan.Zero);
|
||||||
|
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
await using var chunks = source
|
||||||
|
.CaptureAsync(new MeetingAssistantOptions(), cancellation.Token)
|
||||||
|
.GetAsyncEnumerator(cancellation.Token);
|
||||||
|
|
||||||
|
Assert.True(await chunks.MoveNextAsync());
|
||||||
|
Assert.Equal(1_000, BitConverter.ToInt16(chunks.Current.Pcm));
|
||||||
|
Assert.True(await chunks.MoveNextAsync());
|
||||||
|
Assert.Equal(2_000, BitConverter.ToInt16(chunks.Current.Pcm));
|
||||||
|
Assert.Equal(2, captureSources.CaptureCreationCount);
|
||||||
|
|
||||||
|
await cancellation.CancelAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CaptureWaitsForMicrophoneToBecomeAvailable()
|
||||||
|
{
|
||||||
|
var captureSources = new InitiallyUnavailableMicrophoneCaptureSourceFactory(
|
||||||
|
new ActiveAudioSource(Pcm16(3_000)));
|
||||||
|
var source = new MicrophoneAudioSource(
|
||||||
|
captureSources,
|
||||||
|
NullLogger<MicrophoneAudioSource>.Instance,
|
||||||
|
TimeSpan.Zero);
|
||||||
|
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
await using var chunks = source
|
||||||
|
.CaptureAsync(new MeetingAssistantOptions(), cancellation.Token)
|
||||||
|
.GetAsyncEnumerator(cancellation.Token);
|
||||||
|
|
||||||
|
Assert.True(await chunks.MoveNextAsync());
|
||||||
|
Assert.Equal(3_000, BitConverter.ToInt16(chunks.Current.Pcm));
|
||||||
|
Assert.Equal(2, captureSources.CaptureCreationCount);
|
||||||
|
|
||||||
|
await cancellation.CancelAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Pcm16(short sample)
|
||||||
|
{
|
||||||
|
return BitConverter.GetBytes(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SequenceMicrophoneCaptureSourceFactory(params IMeetingAudioSource[] sources)
|
||||||
|
: IMicrophoneCaptureSourceFactory
|
||||||
|
{
|
||||||
|
private readonly Queue<IMeetingAudioSource> sources = new(sources);
|
||||||
|
|
||||||
|
public int CaptureCreationCount { get; private set; }
|
||||||
|
|
||||||
|
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options)
|
||||||
|
{
|
||||||
|
CaptureCreationCount++;
|
||||||
|
return sources.Dequeue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class InitiallyUnavailableMicrophoneCaptureSourceFactory(IMeetingAudioSource availableSource)
|
||||||
|
: IMicrophoneCaptureSourceFactory
|
||||||
|
{
|
||||||
|
public int CaptureCreationCount { get; private set; }
|
||||||
|
|
||||||
|
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options)
|
||||||
|
{
|
||||||
|
CaptureCreationCount++;
|
||||||
|
if (CaptureCreationCount == 1)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("No microphone is currently available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FailingAfterChunkAudioSource(byte[] pcm) : IMeetingAudioSource
|
||||||
|
{
|
||||||
|
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await Task.Yield();
|
||||||
|
yield return new AudioChunk(pcm, 16000, 1);
|
||||||
|
throw new InvalidOperationException("The active microphone was disconnected.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ActiveAudioSource(byte[] pcm) : IMeetingAudioSource
|
||||||
|
{
|
||||||
|
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
yield return new AudioChunk(pcm, 16000, 1);
|
||||||
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,4 +51,17 @@ public sealed class MicrophoneSelectionTests
|
|||||||
|
|
||||||
Assert.Equal("runtime-id", selected?.Id);
|
Assert.Equal("runtime-id", selected?.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnavailableDefaultMicrophoneFallsBackToAnotherActiveDevice()
|
||||||
|
{
|
||||||
|
var selection = new MicrophoneDeviceSelection();
|
||||||
|
|
||||||
|
var selected = selection.Resolve(
|
||||||
|
configuredDeviceId: null,
|
||||||
|
new MicrophoneDevice("disconnected-id", "disconnected microphone"),
|
||||||
|
[new MicrophoneDevice("backup-id", "backup microphone")]);
|
||||||
|
|
||||||
|
Assert.Equal("backup-id", selected?.Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class WorkflowRulesEditorTests
|
|||||||
Key = "summary-key",
|
Key = "summary-key",
|
||||||
KeyEnv = "SUMMARY_KEY",
|
KeyEnv = "SUMMARY_KEY",
|
||||||
Model = "summary-model",
|
Model = "summary-model",
|
||||||
|
UseStreaming = true,
|
||||||
EnableThinking = true,
|
EnableThinking = true,
|
||||||
ReasoningEffort = ReasoningEffortOption.High,
|
ReasoningEffort = ReasoningEffortOption.High,
|
||||||
ReconnectionAttempts = 7,
|
ReconnectionAttempts = 7,
|
||||||
@@ -33,6 +34,7 @@ public sealed class WorkflowRulesEditorTests
|
|||||||
var editor = new WorkflowRulesEditorOptions
|
var editor = new WorkflowRulesEditorOptions
|
||||||
{
|
{
|
||||||
Model = "editor-model",
|
Model = "editor-model",
|
||||||
|
UseStreaming = false,
|
||||||
EnableThinking = false,
|
EnableThinking = false,
|
||||||
MaxOutputTokens = 50
|
MaxOutputTokens = 50
|
||||||
};
|
};
|
||||||
@@ -43,6 +45,7 @@ public sealed class WorkflowRulesEditorTests
|
|||||||
Assert.Equal("summary-key", effective.Key);
|
Assert.Equal("summary-key", effective.Key);
|
||||||
Assert.Equal("SUMMARY_KEY", effective.KeyEnv);
|
Assert.Equal("SUMMARY_KEY", effective.KeyEnv);
|
||||||
Assert.Equal("editor-model", effective.Model);
|
Assert.Equal("editor-model", effective.Model);
|
||||||
|
Assert.False(effective.UseStreaming);
|
||||||
Assert.False(effective.EnableThinking);
|
Assert.False(effective.EnableThinking);
|
||||||
Assert.Equal(ReasoningEffortOption.High, effective.ReasoningEffort);
|
Assert.Equal(ReasoningEffortOption.High, effective.ReasoningEffort);
|
||||||
Assert.Equal(7, effective.ReconnectionAttempts);
|
Assert.Equal(7, effective.ReconnectionAttempts);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||||
<PackageReference Include="NCalcSync" Version="6.4.0" />
|
<PackageReference Include="NCalcSync" Version="6.4.0" />
|
||||||
<PackageReference Include="RazorLight" Version="2.3.1" />
|
<PackageReference Include="RazorLight" Version="2.3.1" />
|
||||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="10.0.10" />
|
<PackageReference Include="System.Drawing.Common" Version="10.0.10" />
|
||||||
<PackageReference Include="Whisper.net" Version="1.9.1" />
|
<PackageReference Include="Whisper.net" Version="1.9.1" />
|
||||||
<PackageReference Include="Whisper.net.Runtime" Version="1.9.1" />
|
<PackageReference Include="Whisper.net.Runtime" Version="1.9.1" />
|
||||||
@@ -65,6 +65,7 @@
|
|||||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
|
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
|
||||||
<Compile Remove="Hotkeys\GlobalHotkeyService.cs" />
|
<Compile Remove="Hotkeys\GlobalHotkeyService.cs" />
|
||||||
<Compile Remove="Recording\NaudioCaptureSource.cs" />
|
<Compile Remove="Recording\NaudioCaptureSource.cs" />
|
||||||
|
<Compile Remove="Recording\WindowsMicrophoneDeviceProvider.cs" />
|
||||||
<Compile Remove="MeetingNotes\OutlookClassicMeetingMetadataProvider.Windows.cs" />
|
<Compile Remove="MeetingNotes\OutlookClassicMeetingMetadataProvider.Windows.cs" />
|
||||||
<Compile Remove="Screenshots\ActiveWindowScreenshotCapture.Windows.cs" />
|
<Compile Remove="Screenshots\ActiveWindowScreenshotCapture.Windows.cs" />
|
||||||
<Compile Remove="Taskbar\UnoTaskbarIconService.Windows.cs" />
|
<Compile Remove="Taskbar\UnoTaskbarIconService.Windows.cs" />
|
||||||
|
|||||||
@@ -383,6 +383,8 @@ public sealed class AgentOptions
|
|||||||
|
|
||||||
public string Model { get; set; } = "chatgpt/gpt-5.5";
|
public string Model { get; set; } = "chatgpt/gpt-5.5";
|
||||||
|
|
||||||
|
public bool UseStreaming { get; set; } = true;
|
||||||
|
|
||||||
public bool EnableThinking { get; set; } = true;
|
public bool EnableThinking { get; set; } = true;
|
||||||
|
|
||||||
public ReasoningEffortOption ReasoningEffort { get; set; } = ReasoningEffortOption.Medium;
|
public ReasoningEffortOption ReasoningEffort { get; set; } = ReasoningEffortOption.Medium;
|
||||||
@@ -414,6 +416,8 @@ public sealed class WorkflowRulesEditorOptions
|
|||||||
|
|
||||||
public string? Model { get; set; }
|
public string? Model { get; set; }
|
||||||
|
|
||||||
|
public bool? UseStreaming { get; set; }
|
||||||
|
|
||||||
public bool? EnableThinking { get; set; }
|
public bool? EnableThinking { get; set; }
|
||||||
|
|
||||||
public ReasoningEffortOption? ReasoningEffort { get; set; }
|
public ReasoningEffortOption? ReasoningEffort { get; set; }
|
||||||
@@ -442,6 +446,7 @@ public sealed class WorkflowRulesEditorOptions
|
|||||||
Key = string.IsNullOrWhiteSpace(Key) ? defaults.Key : Key,
|
Key = string.IsNullOrWhiteSpace(Key) ? defaults.Key : Key,
|
||||||
KeyEnv = string.IsNullOrWhiteSpace(KeyEnv) ? defaults.KeyEnv : KeyEnv!,
|
KeyEnv = string.IsNullOrWhiteSpace(KeyEnv) ? defaults.KeyEnv : KeyEnv!,
|
||||||
Model = string.IsNullOrWhiteSpace(Model) ? defaults.Model : Model!,
|
Model = string.IsNullOrWhiteSpace(Model) ? defaults.Model : Model!,
|
||||||
|
UseStreaming = UseStreaming ?? defaults.UseStreaming,
|
||||||
EnableThinking = EnableThinking ?? defaults.EnableThinking,
|
EnableThinking = EnableThinking ?? defaults.EnableThinking,
|
||||||
ReasoningEffort = ReasoningEffort ?? defaults.ReasoningEffort,
|
ReasoningEffort = ReasoningEffort ?? defaults.ReasoningEffort,
|
||||||
ReconnectionAttempts = ReconnectionAttempts ?? defaults.ReconnectionAttempts,
|
ReconnectionAttempts = ReconnectionAttempts ?? defaults.ReconnectionAttempts,
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ builder.Services.Configure<MeetingAssistantOptions>(builder.Configuration.GetSec
|
|||||||
builder.Services.AddSingleton<ILaunchProfileOptionsProvider, ConfigurationLaunchProfileOptionsProvider>();
|
builder.Services.AddSingleton<ILaunchProfileOptionsProvider, ConfigurationLaunchProfileOptionsProvider>();
|
||||||
#if WINDOWS
|
#if WINDOWS
|
||||||
builder.Services.AddSingleton<MicrophoneDeviceSelection>();
|
builder.Services.AddSingleton<MicrophoneDeviceSelection>();
|
||||||
builder.Services.AddSingleton<IMicrophoneDeviceProvider, WindowsMicrophoneDeviceProvider>();
|
builder.Services.AddSingleton<WindowsMicrophoneDeviceProvider>();
|
||||||
|
builder.Services.AddSingleton<IMicrophoneDeviceProvider>(services =>
|
||||||
|
services.GetRequiredService<WindowsMicrophoneDeviceProvider>());
|
||||||
|
builder.Services.AddSingleton<IMicrophoneCaptureSourceFactory>(services =>
|
||||||
|
services.GetRequiredService<WindowsMicrophoneDeviceProvider>());
|
||||||
builder.Services.AddSingleton<MicrophoneAudioSource>();
|
builder.Services.AddSingleton<MicrophoneAudioSource>();
|
||||||
builder.Services.AddSingleton<SystemAudioSource>();
|
builder.Services.AddSingleton<SystemAudioSource>();
|
||||||
builder.Services.AddSingleton<IAcousticEchoCancellerFactory, AdaptiveFilterAcousticEchoCancellerFactory>();
|
builder.Services.AddSingleton<IAcousticEchoCancellerFactory, AdaptiveFilterAcousticEchoCancellerFactory>();
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MeetingAssistant.Recording;
|
||||||
|
|
||||||
|
public interface IMicrophoneCaptureSourceFactory
|
||||||
|
{
|
||||||
|
IMeetingAudioSource CreateCapture(MeetingAssistantOptions options);
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
using NAudio.Wave;
|
|
||||||
|
|
||||||
namespace MeetingAssistant.Recording;
|
namespace MeetingAssistant.Recording;
|
||||||
|
|
||||||
public interface IMicrophoneDeviceProvider
|
public interface IMicrophoneDeviceProvider
|
||||||
@@ -7,6 +5,4 @@ public interface IMicrophoneDeviceProvider
|
|||||||
IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones();
|
IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones();
|
||||||
|
|
||||||
MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options);
|
MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options);
|
||||||
|
|
||||||
IWaveIn CreateCapture(MeetingAssistantOptions options);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace MeetingAssistant.Recording;
|
||||||
|
|
||||||
|
public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan DefaultRecoveryDelay = TimeSpan.FromSeconds(1);
|
||||||
|
private readonly IMicrophoneCaptureSourceFactory captureSources;
|
||||||
|
private readonly ILogger<MicrophoneAudioSource> logger;
|
||||||
|
private readonly TimeSpan recoveryDelay;
|
||||||
|
|
||||||
|
public MicrophoneAudioSource(
|
||||||
|
IMicrophoneCaptureSourceFactory captureSources,
|
||||||
|
ILogger<MicrophoneAudioSource> logger)
|
||||||
|
: this(captureSources, logger, DefaultRecoveryDelay)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal MicrophoneAudioSource(
|
||||||
|
IMicrophoneCaptureSourceFactory captureSources,
|
||||||
|
ILogger<MicrophoneAudioSource> logger,
|
||||||
|
TimeSpan recoveryDelay)
|
||||||
|
{
|
||||||
|
this.captureSources = captureSources;
|
||||||
|
this.logger = logger;
|
||||||
|
this.recoveryDelay = recoveryDelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return CaptureAsync(new MeetingAssistantOptions(), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var failedAttempts = 0;
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
IAsyncEnumerator<AudioChunk>? capture = null;
|
||||||
|
Exception? failure = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
capture = captureSources
|
||||||
|
.CreateCapture(options)
|
||||||
|
.CaptureAsync(options, cancellationToken)
|
||||||
|
.GetAsyncEnumerator(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
failure = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capture is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var hasNext = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
hasNext = await capture.MoveNextAsync();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
failure = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested || failure is not null || !hasNext)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedAttempts > 0)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Microphone capture recovered after {FailedAttemptCount} failed attempt(s)",
|
||||||
|
failedAttempts);
|
||||||
|
failedAttempts = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return capture.Current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await capture.DisposeAsync();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
failure ??= exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
failedAttempts++;
|
||||||
|
logger.LogWarning(
|
||||||
|
failure,
|
||||||
|
"Microphone capture stopped unexpectedly; re-resolving an available microphone in {RecoveryDelay}",
|
||||||
|
recoveryDelay);
|
||||||
|
|
||||||
|
if (!await WaitForRecoveryAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> WaitForRecoveryAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(recoveryDelay, cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ public sealed class MicrophoneDeviceSelection
|
|||||||
var selected = SelectedDeviceId;
|
var selected = SelectedDeviceId;
|
||||||
return FindById(availableDevices, selected) ??
|
return FindById(availableDevices, selected) ??
|
||||||
FindById(availableDevices, configuredDeviceId) ??
|
FindById(availableDevices, configuredDeviceId) ??
|
||||||
|
FindById(availableDevices, defaultDevice?.Id) ??
|
||||||
|
availableDevices.FirstOrDefault() ??
|
||||||
defaultDevice;
|
defaultDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,41 +4,28 @@ using NAudio.Wave;
|
|||||||
|
|
||||||
namespace MeetingAssistant.Recording;
|
namespace MeetingAssistant.Recording;
|
||||||
|
|
||||||
public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
internal sealed class NaudioCaptureAudioSource : IMeetingAudioSource
|
||||||
{
|
{
|
||||||
private readonly IMicrophoneDeviceProvider microphones;
|
private readonly IWaveIn capture;
|
||||||
private readonly ILogger<MicrophoneAudioSource> logger;
|
private readonly string sourceName;
|
||||||
|
private readonly ILogger logger;
|
||||||
|
|
||||||
public MicrophoneAudioSource(
|
public NaudioCaptureAudioSource(
|
||||||
IMicrophoneDeviceProvider microphones,
|
IWaveIn capture,
|
||||||
ILogger<MicrophoneAudioSource> logger)
|
string sourceName,
|
||||||
|
ILogger logger)
|
||||||
{
|
{
|
||||||
this.microphones = microphones;
|
this.capture = capture;
|
||||||
|
this.sourceName = sourceName;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return CaptureAsync(new MeetingAssistantOptions(), cancellationToken);
|
return CaptureWith(capture, sourceName, logger, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(
|
private static async IAsyncEnumerable<AudioChunk> CaptureWith(
|
||||||
MeetingAssistantOptions options,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return CaptureAsync(microphones.CreateCapture(options), options, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private IAsyncEnumerable<AudioChunk> CaptureAsync(
|
|
||||||
IWaveIn capture,
|
|
||||||
MeetingAssistantOptions options,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
capture.WaveFormat = new WaveFormat(options.Recording.SampleRate, 16, options.Recording.Channels);
|
|
||||||
return CaptureWith(capture, "microphone", logger, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static async IAsyncEnumerable<AudioChunk> CaptureWith(
|
|
||||||
IWaveIn capture,
|
IWaveIn capture,
|
||||||
string sourceName,
|
string sourceName,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
@@ -124,6 +111,6 @@ public sealed class SystemAudioSource : IMeetingAudioSource
|
|||||||
WaveFormat = new WaveFormat(options.Recording.SampleRate, 16, options.Recording.Channels)
|
WaveFormat = new WaveFormat(options.Recording.SampleRate, 16, options.Recording.Channels)
|
||||||
};
|
};
|
||||||
|
|
||||||
return MicrophoneAudioSource.CaptureWith(capture, "system", logger, cancellationToken);
|
return new NaudioCaptureAudioSource(capture, "system", logger).CaptureAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using NAudio.Wave;
|
|||||||
|
|
||||||
namespace MeetingAssistant.Recording;
|
namespace MeetingAssistant.Recording;
|
||||||
|
|
||||||
public sealed class WindowsMicrophoneDeviceProvider : IMicrophoneDeviceProvider
|
public sealed class WindowsMicrophoneDeviceProvider : IMicrophoneDeviceProvider, IMicrophoneCaptureSourceFactory
|
||||||
{
|
{
|
||||||
private readonly MicrophoneDeviceSelection selection;
|
private readonly MicrophoneDeviceSelection selection;
|
||||||
private readonly ILogger<WindowsMicrophoneDeviceProvider> logger;
|
private readonly ILogger<WindowsMicrophoneDeviceProvider> logger;
|
||||||
@@ -42,21 +42,38 @@ public sealed class WindowsMicrophoneDeviceProvider : IMicrophoneDeviceProvider
|
|||||||
selection.Resolve(options.Recording.MicrophoneDeviceId, GetDefaultMicrophone(), devices));
|
selection.Resolve(options.Recording.MicrophoneDeviceId, GetDefaultMicrophone(), devices));
|
||||||
}
|
}
|
||||||
|
|
||||||
public IWaveIn CreateCapture(MeetingAssistantOptions options)
|
public IMeetingAudioSource CreateCapture(MeetingAssistantOptions options)
|
||||||
{
|
{
|
||||||
var current = GetMicrophoneSnapshot(options).Current;
|
var current = GetMicrophoneSnapshot(options).Current;
|
||||||
|
IWaveIn capture;
|
||||||
if (current is null)
|
if (current is null)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Starting microphone capture from Windows default capture endpoint");
|
logger.LogInformation("Starting microphone capture from Windows default capture endpoint");
|
||||||
return new WasapiCapture();
|
capture = new WasapiCapture();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Starting microphone capture from {MicrophoneName} ({MicrophoneDeviceId})",
|
"Starting microphone capture from {MicrophoneName} ({MicrophoneDeviceId})",
|
||||||
current.Name,
|
current.Name,
|
||||||
current.Id);
|
current.Id);
|
||||||
using var enumerator = new MMDeviceEnumerator();
|
using var enumerator = new MMDeviceEnumerator();
|
||||||
return new WasapiCapture(enumerator.GetDevice(current.Id));
|
capture = new WasapiCapture(enumerator.GetDevice(current.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
capture.WaveFormat = new WaveFormat(
|
||||||
|
options.Recording.SampleRate,
|
||||||
|
16,
|
||||||
|
options.Recording.Channels);
|
||||||
|
return new NaudioCaptureAudioSource(capture, "microphone", logger);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
capture.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MicrophoneDevice? GetDefaultMicrophone()
|
private static MicrophoneDevice? GetDefaultMicrophone()
|
||||||
|
|||||||
@@ -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.Net.Http.Headers;
|
||||||
|
using System.ClientModel;
|
||||||
|
using System.ClientModel.Primitives;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using Microsoft.Agents.AI.Compaction;
|
using Microsoft.Agents.AI.Compaction;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using OpenAI;
|
||||||
|
using OpenAI.Responses;
|
||||||
|
|
||||||
namespace MeetingAssistant.Summary;
|
namespace MeetingAssistant.Summary;
|
||||||
|
|
||||||
#pragma warning disable MAAI001
|
#pragma warning disable MAAI001
|
||||||
|
#pragma warning disable OPENAI001
|
||||||
public sealed class LiteLlmResponsesChatClient : IChatClient
|
public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
private readonly HttpClient httpClient;
|
private readonly HttpClient httpClient;
|
||||||
|
private readonly ResponsesClient responsesClient;
|
||||||
|
private readonly AsyncLocal<string?> requestInitiator = new();
|
||||||
private readonly string model;
|
private readonly string model;
|
||||||
|
private readonly bool useStreaming;
|
||||||
private readonly bool enableThinking;
|
private readonly bool enableThinking;
|
||||||
private readonly string reasoningEffort;
|
private readonly string reasoningEffort;
|
||||||
private readonly int reconnectionAttempts;
|
private readonly int reconnectionAttempts;
|
||||||
@@ -37,7 +45,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
|||||||
ILogger? logger = null,
|
ILogger? logger = null,
|
||||||
bool firstRequestIsUser = true,
|
bool firstRequestIsUser = true,
|
||||||
Action? retrying = null,
|
Action? retrying = null,
|
||||||
Action<string>? reasoningSummaryChanged = null)
|
Action<string>? reasoningSummaryChanged = null,
|
||||||
|
bool useStreaming = true)
|
||||||
: this(
|
: this(
|
||||||
new HttpClient { BaseAddress = NormalizeEndpoint(endpoint) },
|
new HttpClient { BaseAddress = NormalizeEndpoint(endpoint) },
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -50,7 +59,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
|||||||
logger,
|
logger,
|
||||||
firstRequestIsUser,
|
firstRequestIsUser,
|
||||||
retrying,
|
retrying,
|
||||||
reasoningSummaryChanged)
|
reasoningSummaryChanged,
|
||||||
|
useStreaming)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +76,14 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
|||||||
ILogger? logger = null,
|
ILogger? logger = null,
|
||||||
bool firstRequestIsUser = true,
|
bool firstRequestIsUser = true,
|
||||||
Action? retrying = null,
|
Action? retrying = null,
|
||||||
Action<string>? reasoningSummaryChanged = null)
|
Action<string>? reasoningSummaryChanged = null,
|
||||||
|
bool useStreaming = true)
|
||||||
{
|
{
|
||||||
this.httpClient = httpClient;
|
this.httpClient = httpClient;
|
||||||
this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||||
|
responsesClient = CreateResponsesClient(httpClient, apiKey, requestInitiator);
|
||||||
this.model = model;
|
this.model = model;
|
||||||
|
this.useStreaming = useStreaming;
|
||||||
this.enableThinking = enableThinking;
|
this.enableThinking = enableThinking;
|
||||||
this.reasoningEffort = reasoningEffort;
|
this.reasoningEffort = reasoningEffort;
|
||||||
this.reconnectionAttempts = Math.Max(0, reconnectionAttempts);
|
this.reconnectionAttempts = Math.Max(0, reconnectionAttempts);
|
||||||
@@ -87,20 +100,50 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
|||||||
httpClient.Dispose();
|
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(
|
public async Task<ChatResponse> GetResponseAsync(
|
||||||
IEnumerable<ChatMessage> messages,
|
IEnumerable<ChatMessage> messages,
|
||||||
ChatOptions? options = null,
|
ChatOptions? options = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var payload = await CreateCompactedPayloadAsync(messages.ToList(), options, cancellationToken).ConfigureAwait(false);
|
var payload = await CreateCompactedPayloadAsync(messages.ToList(), options, cancellationToken).ConfigureAwait(false);
|
||||||
var responseJson = await PostWithRetryAsync(payload, cancellationToken).ConfigureAwait(false);
|
var result = await PostWithRetryAsync(payload, cancellationToken).ConfigureAwait(false);
|
||||||
|
ReportVisibleReasoningSummaries(result.ReasoningSummaries);
|
||||||
var response = ParseResponseJson(responseJson, out var reasoningSummaries);
|
LogResponseDiagnostics(result.Response);
|
||||||
ReportVisibleReasoningSummaries(reasoningSummaries);
|
ThrowIfResponseHasNoContent(result.Response);
|
||||||
LogResponseDiagnostics(responseJson, response);
|
LogResponseUsage(result.Response.Usage);
|
||||||
ThrowIfResponseHasNoContent(responseJson, response);
|
return result.Response;
|
||||||
LogResponseUsage(response.Usage);
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||||
@@ -127,79 +170,14 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ChatResponse ParseResponseJson(string responseJson)
|
private static IReadOnlyList<string> ExtractVisibleReasoningSummaries(ChatResponse response)
|
||||||
{
|
{
|
||||||
return ParseResponseJson(responseJson, out _);
|
return response.Messages
|
||||||
}
|
.SelectMany(message => message.Contents)
|
||||||
|
.OfType<TextReasoningContent>()
|
||||||
private static ChatResponse ParseResponseJson(
|
.Select(content => content.Text)
|
||||||
string responseJson,
|
.Where(text => !string.IsNullOrWhiteSpace(text))
|
||||||
out IReadOnlyList<string> reasoningSummaries)
|
.ToArray();
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ReportVisibleReasoningSummaries(IReadOnlyList<string> summaries)
|
private void ReportVisibleReasoningSummaries(IReadOnlyList<string> summaries)
|
||||||
@@ -407,7 +385,9 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
|||||||
return payload;
|
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);
|
var payloadJson = payload.ToJsonString(JsonOptions);
|
||||||
Exception? lastException = null;
|
Exception? lastException = null;
|
||||||
@@ -418,26 +398,36 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
|||||||
{
|
{
|
||||||
var initiator = NextInitiator();
|
var initiator = NextInitiator();
|
||||||
LogRequestDiagnostics(payload, initiator, attempt);
|
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));
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
var exception = new InvalidOperationException(
|
|
||||||
$"LiteLLM Responses request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {responseJson}");
|
|
||||||
if (!IsRetryableStatusCode((int)response.StatusCode) || attempt == reconnectionAttempts)
|
|
||||||
{
|
{
|
||||||
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)
|
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.");
|
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(
|
private HttpRequestMessage CreateJsonRequest(
|
||||||
string requestUri,
|
string requestUri,
|
||||||
string payloadJson,
|
string payloadJson,
|
||||||
@@ -489,7 +521,9 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
|||||||
|
|
||||||
private static bool IsRetryableException(Exception exception)
|
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)
|
private void LogContextWindow(int estimatedTokens, bool compacted, string source)
|
||||||
@@ -561,18 +595,18 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
|||||||
instructionsPreview);
|
instructionsPreview);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LogResponseDiagnostics(string responseJson, ChatResponse response)
|
private void LogResponseDiagnostics(ChatResponse response)
|
||||||
{
|
{
|
||||||
if (logger is null)
|
if (logger is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var outputTypes = GetOutputItemTypes(responseJson);
|
|
||||||
var parsedTypes = response.Messages
|
var parsedTypes = response.Messages
|
||||||
.SelectMany(message => message.Contents)
|
.SelectMany(message => message.Contents)
|
||||||
.Select(content => content.GetType().Name)
|
.Select(content => content.GetType().Name)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
var outputTypes = GetOutputItemTypes(response);
|
||||||
var functionCalls = response.Messages
|
var functionCalls = response.Messages
|
||||||
.SelectMany(message => message.Contents)
|
.SelectMany(message => message.Contents)
|
||||||
.OfType<FunctionCallContent>()
|
.OfType<FunctionCallContent>()
|
||||||
@@ -590,40 +624,29 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
|||||||
if (parsedTypes.Length == 0)
|
if (parsedTypes.Length == 0)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"LiteLLM Responses response contained no parseable message text or function calls. Raw response preview: {ResponsePreview}",
|
"LiteLLM Responses response contained no parseable message text or function calls.");
|
||||||
Truncate(responseJson, maxLength: 6000));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ThrowIfResponseHasNoContent(string responseJson, ChatResponse response)
|
private static void ThrowIfResponseHasNoContent(ChatResponse response)
|
||||||
{
|
{
|
||||||
if (response.Messages.SelectMany(message => message.Contents).Any())
|
if (response.Messages.SelectMany(message => message.Contents).Any())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var outputTypes = GetOutputItemTypes(responseJson);
|
var outputTypes = GetOutputItemTypes(response);
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"LiteLLM Responses returned no parseable message text or function calls. " +
|
"LiteLLM Responses returned no parseable message text or function calls. " +
|
||||||
$"ResponseId={response.ResponseId ?? "<none>"}, outputTypes=[{string.Join(", ", outputTypes)}].");
|
$"ResponseId={response.ResponseId ?? "<none>"}, outputTypes=[{string.Join(", ", outputTypes)}].");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string[] GetOutputItemTypes(string responseJson)
|
private static string[] GetOutputItemTypes(ChatResponse response)
|
||||||
{
|
{
|
||||||
try
|
return response.Messages
|
||||||
{
|
.SelectMany(message => message.Contents)
|
||||||
using var document = JsonDocument.Parse(responseJson);
|
.Select(content => content.RawRepresentation?.GetType().Name ?? content.GetType().Name)
|
||||||
return document.RootElement.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array
|
.ToArray();
|
||||||
? output
|
|
||||||
.EnumerateArray()
|
|
||||||
.Select(item => GetString(item, "type") ?? item.ValueKind.ToString())
|
|
||||||
.ToArray()
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
return ["<invalid-json>"];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Truncate(string value, int maxLength)
|
private static string Truncate(string value, int maxLength)
|
||||||
@@ -649,10 +672,19 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(text))
|
if (!string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
|
var isAssistant = role == ChatRole.Assistant.Value;
|
||||||
input.Add(new JsonObject
|
input.Add(new JsonObject
|
||||||
{
|
{
|
||||||
["role"] = role == ChatRole.Assistant.Value ? "assistant" : "user",
|
["type"] = "message",
|
||||||
["content"] = text
|
["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",
|
["type"] = "function_call",
|
||||||
["call_id"] = call.CallId,
|
["call_id"] = call.CallId,
|
||||||
["name"] = call.Name,
|
["name"] = call.Name,
|
||||||
["arguments"] = JsonSerializer.Serialize(call.Arguments, JsonOptions)
|
["arguments"] = SerializeFunctionArguments(call)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else if (content is FunctionResultContent result)
|
else if (content is FunctionResultContent result)
|
||||||
@@ -702,110 +734,15 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
|||||||
return result;
|
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())
|
return JsonSerializer.Serialize(call.Arguments, JsonOptions);
|
||||||
{
|
|
||||||
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)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResultToString(object? result)
|
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)
|
private static int EstimateTokens(JsonObject payload)
|
||||||
{
|
{
|
||||||
var json = payload.ToJsonString(JsonOptions);
|
var json = payload.ToJsonString(JsonOptions);
|
||||||
return Math.Max(1, (int)Math.Ceiling(json.Length / 4.0));
|
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)
|
private static Uri NormalizeEndpoint(Uri endpoint)
|
||||||
{
|
{
|
||||||
var value = endpoint.ToString().TrimEnd('/');
|
var value = endpoint.ToString().TrimEnd('/');
|
||||||
@@ -884,5 +776,60 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
|||||||
{
|
{
|
||||||
return value.TrimStart('/');
|
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
|
#pragma warning restore MAAI001
|
||||||
|
|||||||
@@ -63,32 +63,24 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
|||||||
meetingWorkflowEngine);
|
meetingWorkflowEngine);
|
||||||
var tools = CreateTools(meetingTools);
|
var tools = CreateTools(meetingTools);
|
||||||
var instructions = await instructionBuilder.BuildAsync(artifacts, options, cancellationToken);
|
var instructions = await instructionBuilder.BuildAsync(artifacts, options, cancellationToken);
|
||||||
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
|
using var compactionSummaryClient = LiteLlmResponsesChatClient.Create(
|
||||||
new Uri(agentOptions.Endpoint),
|
agentOptions,
|
||||||
key,
|
key,
|
||||||
agentOptions.Model,
|
|
||||||
agentOptions.EnableThinking,
|
|
||||||
ToReasoningEffortValue(agentOptions.ReasoningEffort),
|
|
||||||
agentOptions.ReconnectionAttempts,
|
|
||||||
agentOptions.ReconnectionDelay,
|
|
||||||
compactionOptions: null,
|
compactionOptions: null,
|
||||||
logger,
|
logger,
|
||||||
firstRequestIsUser: false);
|
firstRequestIsUser: false);
|
||||||
var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient);
|
var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient);
|
||||||
using var chatClient = new LiteLlmResponsesChatClient(
|
using var chatClient = LiteLlmResponsesChatClient.Create(
|
||||||
new Uri(agentOptions.Endpoint),
|
agentOptions,
|
||||||
key,
|
key,
|
||||||
agentOptions.Model,
|
|
||||||
agentOptions.EnableThinking,
|
|
||||||
ToReasoningEffortValue(agentOptions.ReasoningEffort),
|
|
||||||
agentOptions.ReconnectionAttempts,
|
|
||||||
agentOptions.ReconnectionDelay,
|
|
||||||
compactionOptions,
|
compactionOptions,
|
||||||
logger,
|
logger,
|
||||||
firstRequestIsUser: false);
|
firstRequestIsUser: false);
|
||||||
var agent = chatClient
|
var agent = chatClient
|
||||||
.AsBuilder()
|
.AsBuilder()
|
||||||
.UseFunctionInvocation()
|
.UseFunctionInvocation(
|
||||||
|
loggerFactory,
|
||||||
|
client => client.FunctionInvoker = FunctionInvocationGuard.InvokeAsync)
|
||||||
.Build()
|
.Build()
|
||||||
.AsAIAgent(
|
.AsAIAgent(
|
||||||
instructions,
|
instructions,
|
||||||
@@ -312,18 +304,6 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
|||||||
return new PipelineCompactionStrategy(strategies);
|
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)
|
private static ReasoningEffort ToReasoningEffort(ReasoningEffortOption effort)
|
||||||
{
|
{
|
||||||
return effort switch
|
return effort switch
|
||||||
|
|||||||
@@ -85,26 +85,16 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
|||||||
.ToList();
|
.ToList();
|
||||||
var instructions = await instructionBuilder.BuildAsync(options, cancellationToken);
|
var instructions = await instructionBuilder.BuildAsync(options, cancellationToken);
|
||||||
|
|
||||||
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
|
using var compactionSummaryClient = LiteLlmResponsesChatClient.Create(
|
||||||
new Uri(agentOptions.Endpoint),
|
agentOptions,
|
||||||
key,
|
key,
|
||||||
agentOptions.Model,
|
|
||||||
agentOptions.EnableThinking,
|
|
||||||
ToReasoningEffortValue(agentOptions.ReasoningEffort),
|
|
||||||
agentOptions.ReconnectionAttempts,
|
|
||||||
agentOptions.ReconnectionDelay,
|
|
||||||
compactionOptions: null,
|
compactionOptions: null,
|
||||||
logger,
|
logger,
|
||||||
firstRequestIsUser: false);
|
firstRequestIsUser: false);
|
||||||
var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient);
|
var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient);
|
||||||
using var chatClient = new LiteLlmResponsesChatClient(
|
using var chatClient = LiteLlmResponsesChatClient.Create(
|
||||||
new Uri(agentOptions.Endpoint),
|
agentOptions,
|
||||||
key,
|
key,
|
||||||
agentOptions.Model,
|
|
||||||
agentOptions.EnableThinking,
|
|
||||||
ToReasoningEffortValue(agentOptions.ReasoningEffort),
|
|
||||||
agentOptions.ReconnectionAttempts,
|
|
||||||
agentOptions.ReconnectionDelay,
|
|
||||||
compactionOptions,
|
compactionOptions,
|
||||||
logger,
|
logger,
|
||||||
firstRequestIsUser: true,
|
firstRequestIsUser: true,
|
||||||
@@ -115,9 +105,13 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
|||||||
.UseFunctionInvocation(loggerFactory, client =>
|
.UseFunctionInvocation(loggerFactory, client =>
|
||||||
{
|
{
|
||||||
client.FunctionInvoker = async (context, token) =>
|
client.FunctionInvoker = async (context, token) =>
|
||||||
|
{
|
||||||
|
if (context.CallContent.Exception is null)
|
||||||
{
|
{
|
||||||
activityChanged?.Invoke(WorkflowRulesEditorActivityUpdate.ToolCall(context.Function.Name));
|
activityChanged?.Invoke(WorkflowRulesEditorActivityUpdate.ToolCall(context.Function.Name));
|
||||||
return await context.Function.InvokeAsync(context.Arguments, token);
|
}
|
||||||
|
|
||||||
|
return await FunctionInvocationGuard.InvokeAsync(context, token);
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
@@ -396,18 +390,6 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
|||||||
$"No {agentName} API key configured. Set MeetingAssistant:WorkflowRulesEditor:Key, MeetingAssistant:Agent:Key, or environment variable '{options.KeyEnv}'.");
|
$"No {agentName} API key configured. Set MeetingAssistant:WorkflowRulesEditor:Key, MeetingAssistant:Agent:Key, or environment variable '{options.KeyEnv}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
private static ReasoningEffort ToReasoningEffort(ReasoningEffortOption effort)
|
||||||
{
|
{
|
||||||
return effort switch
|
return effort switch
|
||||||
|
|||||||
@@ -172,6 +172,7 @@
|
|||||||
"Endpoint": "http://127.0.0.1:4021",
|
"Endpoint": "http://127.0.0.1:4021",
|
||||||
"KeyEnv": "LITELLM_API_KEY",
|
"KeyEnv": "LITELLM_API_KEY",
|
||||||
"Model": "chatgpt/gpt-5.5",
|
"Model": "chatgpt/gpt-5.5",
|
||||||
|
"UseStreaming": true,
|
||||||
"EnableThinking": true,
|
"EnableThinking": true,
|
||||||
"ReasoningEffort": "Medium",
|
"ReasoningEffort": "Medium",
|
||||||
"ReconnectionAttempts": 2,
|
"ReconnectionAttempts": 2,
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ When enabled on Windows, Meeting Assistant periodically syncs today's Outlook Cl
|
|||||||
| `Key` | Optional inline API key. Prefer `KeyEnv`. |
|
| `Key` | Optional inline API key. Prefer `KeyEnv`. |
|
||||||
| `KeyEnv` | Environment variable name for the agent API key. |
|
| `KeyEnv` | Environment variable name for the agent API key. |
|
||||||
| `Model` | Model id sent to the endpoint. |
|
| `Model` | Model id sent to the endpoint. |
|
||||||
|
| `UseStreaming` | Uses the Responses SSE transport when `true` (the default); uses the non-streaming Responses transport when `false`. |
|
||||||
| `EnableThinking` | Enables reasoning options in requests when supported by the model/backend. |
|
| `EnableThinking` | Enables reasoning options in requests when supported by the model/backend. |
|
||||||
| `ReasoningEffort` | Reasoning effort: `None`, `Low`, `Medium`, `High`, or `ExtraHigh`. |
|
| `ReasoningEffort` | Reasoning effort: `None`, `Low`, `Medium`, `High`, or `ExtraHigh`. |
|
||||||
| `ReconnectionAttempts` | Retry count for transient model endpoint failures. |
|
| `ReconnectionAttempts` | Retry count for transient model endpoint failures. |
|
||||||
@@ -360,9 +361,9 @@ The summary agent can add and remove meeting-note attendees when transcript or O
|
|||||||
|
|
||||||
## Workflow Rules Editor
|
## Workflow Rules Editor
|
||||||
|
|
||||||
`WorkflowRulesEditor` configures the tray-launched `Meeting Summary Agent` window. Blank values inherit from `Agent`, so it uses the summarizer endpoint, key, model, reasoning, retry, output, and compaction settings unless explicitly overridden.
|
`WorkflowRulesEditor` configures the tray-launched `Meeting Summary Agent` window. Blank values inherit from `Agent`, so it uses the summarizer endpoint, key, model, Responses transport, reasoning, retry, output, and compaction settings unless explicitly overridden.
|
||||||
|
|
||||||
The overridable fields are `Endpoint`, `Key`, `KeyEnv`, `Model`, `EnableThinking`, `ReasoningEffort`, `ReconnectionAttempts`, `ReconnectionDelay`, `ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, `CompactionRemainingRatio`, `ResponsesCompactPath`, and `InitialPrompt`.
|
The overridable fields are `Endpoint`, `Key`, `KeyEnv`, `Model`, `UseStreaming`, `EnableThinking`, `ReasoningEffort`, `ReconnectionAttempts`, `ReconnectionDelay`, `ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, `CompactionRemainingRatio`, `ResponsesCompactPath`, and `InitialPrompt`.
|
||||||
|
|
||||||
The assistant can edit workflow rules, manage speaker identities, read and replace the local appsettings file, read this configuration document, search and read copied OpenSpec specs, inspect application logs, create/search/read/write project files, and post-process past meeting artifacts by listing recent summaries and reading, writing, or searching summaries, transcripts, meeting notes, and assistant context files. For artifact metadata repairs, it has frontmatter-specific write tools that preserve the markdown body.
|
The assistant can edit workflow rules, manage speaker identities, read and replace the local appsettings file, read this configuration document, search and read copied OpenSpec specs, inspect application logs, create/search/read/write project files, and post-process past meeting artifacts by listing recent summaries and reading, writing, or searching summaries, transcripts, meeting notes, and assistant context files. For artifact metadata repairs, it has frontmatter-specific write tools that preserve the markdown body.
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ The tray menu includes `Open agent`, which opens the `Meeting Summary Agent` cha
|
|||||||
"Endpoint": "",
|
"Endpoint": "",
|
||||||
"KeyEnv": "",
|
"KeyEnv": "",
|
||||||
"Model": "",
|
"Model": "",
|
||||||
|
"UseStreaming": null,
|
||||||
"EnableThinking": null,
|
"EnableThinking": null,
|
||||||
"ReasoningEffort": null,
|
"ReasoningEffort": null,
|
||||||
"MaxOutputTokens": null,
|
"MaxOutputTokens": null,
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-27
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
`LiteLlmResponsesChatClient` exposes a non-streaming `IChatClient` interface and currently buffers every successful `/v1/responses` body before parsing it as one JSON document. The deployed LiteLLM `chatgpt/gpt-5.5` route instead returns Responses Server-Sent Events. Microsoft.Extensions.AI.OpenAI already provides a Responses streaming adapter that maps the OpenAI SDK's typed SSE updates into `ChatResponseUpdate` values, including text, reasoning, function calls, response metadata, and usage.
|
||||||
|
|
||||||
|
That adapter uses `FunctionCallContent.CreateFromParsedArguments`, which records argument-mapping failures on the function call instead of throwing them from the response parser. The default function invoker does not itself prevent invocation when that property is populated, so Meeting Assistant must turn the recorded parse failure into a matching function result before calling the tool.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Preserve the existing non-streaming `IChatClient` contract while selecting the supported Responses streaming or non-streaming path through configuration.
|
||||||
|
- Delegate SSE framing, event deserialization, streamed function-call assembly, response metadata, and usage mapping to the OpenAI SDK and Microsoft.Extensions.AI.OpenAI adapter.
|
||||||
|
- Preserve the original encoded function arguments when forwarding conversation history.
|
||||||
|
- Return a safe, structured invalid-arguments result for malformed argument JSON without invoking the requested tool.
|
||||||
|
- Apply the guarded invocation behavior to both summary and workflow-editor agent pipelines that use this Responses client.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Expose token-by-token upstream streaming to the UI.
|
||||||
|
- Replace Microsoft.Extensions.AI function invocation or implement general JSON Schema validation.
|
||||||
|
- Recover from an incomplete or malformed Responses event stream that has no usable completed output.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. Add `Agent:UseStreaming`, defaulting to `true`, and send summary requests through the matching OpenAI SDK method. The streaming path uses `ResponsesClient.CreateResponseStreamingAsync` and collects the Microsoft.Extensions.AI.OpenAI typed updates into one `ChatResponse`; the non-streaming path uses `ResponsesClient.CreateResponseAsync` and its supported `AsChatResponse` adapter.
|
||||||
|
|
||||||
|
2. Keep the existing JSON payload builder because it owns Meeting Assistant-specific compaction input, tool serialization, reasoning settings, retry diagnostics, and initiator behavior. Deserialize that payload into the OpenAI SDK's `CreateResponseOptions`, set its streaming flag from configuration, and let the selected SDK method own the HTTP response protocol.
|
||||||
|
|
||||||
|
The SDK deserializes `ResponseItem` values by their `type` discriminator. Message items must therefore include `"type": "message"` before the JSON payload is converted to `CreateResponseOptions`; otherwise the SDK reserializes them as `"type": "unknown"` and the provider rejects the request.
|
||||||
|
|
||||||
|
3. Rely on the framework adapter's `FunctionCallContent.CreateFromParsedArguments` mapping for function-call argument decoding and raw response preservation. A shared function-invocation callback checks the recorded parse exception before invocation. For invalid JSON it returns a structured `invalid_tool_arguments` value that the framework associates with the original call ID; otherwise it delegates to the actual function.
|
||||||
|
|
||||||
|
4. Configure both agent pipelines with the shared guarded invoker. This avoids duplicating the safety decision and prevents zero-argument tools from accidentally running when malformed JSON would otherwise map to an empty argument set.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[Provider event variants]** A provider could emit events outside the OpenAI Responses schema. → Use the maintained OpenAI SDK parser and fail diagnostically for genuinely incompatible streams instead of maintaining local event variants.
|
||||||
|
- **[Buffered result]** The summary pipeline does not expose token-by-token updates to its caller. → Collect the framework updates only at the existing non-streaming boundary; the HTTP response itself remains streamed and incrementally parsed.
|
||||||
|
- **[Experimental API]** The OpenAI Responses adapter is marked experimental in the currently referenced package. → Keep it behind `LiteLlmResponsesChatClient`, which isolates future package API changes from the rest of Meeting Assistant.
|
||||||
|
- **[Error disclosure]** Raw parser exceptions may contain implementation details. → Return a stable error code and concise validation message rather than exception text or the malformed arguments.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The configured LiteLLM Responses endpoint can return successful responses as Server-Sent Events even when the request sets `stream: false`. Meeting Assistant currently treats every successful body as one JSON document, and it also lets malformed function-call argument JSON escape as a fatal parse exception, so either condition can abort an otherwise recoverable summary run.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Consume the configured LiteLLM Responses endpoint through the supported OpenAI Responses streaming transport and Agent Framework adapter.
|
||||||
|
- Assemble completed response output items and usage metadata from Responses SSE events before returning them through the existing non-streaming chat-client interface.
|
||||||
|
- Add an agent setting that selects streaming or non-streaming Responses transport, with streaming enabled by default.
|
||||||
|
- Preserve valid Responses item discriminators when translating agent messages through the OpenAI SDK request model.
|
||||||
|
- Treat malformed function-call arguments as invalid tool input that is returned to the agent for correction without invoking the tool or terminating the summary run.
|
||||||
|
- Add behavior tests for streamed text, streamed function calls, and invalid function-call arguments.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- None.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `meeting-summary`: Make the existing Responses-based summary pipeline interoperable with SSE responses and resilient to malformed tool-call input.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
The change affects agent configuration, the custom LiteLLM Responses chat client, its summary-agent integration, and focused tests. It does not change the public HTTP API.
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Summary agents tolerate Responses event streams and malformed tool arguments
|
||||||
|
Meeting Assistant SHALL consume successful summary-agent Responses results through the supported OpenAI Responses and Agent Framework Server-Sent Events adapter.
|
||||||
|
|
||||||
|
When a Responses event stream delivers completed output items separately from the final response metadata, Meeting Assistant SHALL assemble those output items into one agent response while preserving final response metadata and usage.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL provide an agent setting that selects streaming or non-streaming Responses transport. Streaming SHALL be enabled by default. When streaming is disabled, Meeting Assistant SHALL use the supported non-streaming OpenAI Responses client and adapter.
|
||||||
|
|
||||||
|
When a returned function call contains arguments that are not a valid JSON object, Meeting Assistant SHALL NOT invoke the requested function and SHALL return an invalid-tool-arguments result associated with the original call ID to the agent so it can correct the call.
|
||||||
|
|
||||||
|
When Meeting Assistant translates chat messages into a Responses request, every message input item SHALL retain the `message` item discriminator required by the OpenAI SDK and Responses API.
|
||||||
|
|
||||||
|
#### Scenario: Streamed text response is assembled
|
||||||
|
- **WHEN** the configured Responses endpoint returns completed message output in Server-Sent Events followed by final response metadata
|
||||||
|
- **THEN** the summary agent receives the completed message text, response metadata, and usage without a JSON document parse failure
|
||||||
|
|
||||||
|
#### Scenario: Streamed function call is assembled
|
||||||
|
- **WHEN** the configured Responses endpoint returns a completed function-call output item in Server-Sent Events
|
||||||
|
- **THEN** the summary agent receives the function call with its call ID, function name, and parsed arguments
|
||||||
|
|
||||||
|
#### Scenario: Streaming transport can be disabled
|
||||||
|
- **WHEN** `MeetingAssistant:Agent:UseStreaming` is `false`
|
||||||
|
- **THEN** the summary agent requests a non-streaming Responses result
|
||||||
|
- **AND** converts the response through the supported OpenAI Responses adapter
|
||||||
|
|
||||||
|
#### Scenario: Chat message input remains a Responses message
|
||||||
|
- **WHEN** the summary agent sends a user or assistant chat message through the Responses client
|
||||||
|
- **THEN** the outbound Responses input item has type `message`
|
||||||
|
- **AND** the request does not contain an `unknown` input item type
|
||||||
|
|
||||||
|
#### Scenario: Malformed function arguments are returned to the agent
|
||||||
|
- **WHEN** the model returns a function call whose arguments are not a valid JSON object
|
||||||
|
- **THEN** Meeting Assistant does not invoke the requested function
|
||||||
|
- **AND** sends a function result with an invalid-tool-arguments error for the original call ID back to the agent
|
||||||
|
- **AND** allows the agent loop to continue
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
## 1. Responses SSE compatibility
|
||||||
|
|
||||||
|
- [x] 1.1 Add a failing client behavior test for a streamed text response with final metadata and usage.
|
||||||
|
- [x] 1.2 Add failing configuration and client behavior tests for selecting non-streaming Responses transport.
|
||||||
|
- [x] 1.3 Route Responses requests through the Agent Framework/OpenAI SDK streaming or non-streaming adapter according to configuration.
|
||||||
|
- [x] 1.4 Add behavior coverage for streamed function-call output.
|
||||||
|
|
||||||
|
## 2. Invalid tool-argument recovery
|
||||||
|
|
||||||
|
- [x] 2.1 Add a failing agent-loop behavior test proving malformed function-call JSON does not invoke the tool and is returned to the agent.
|
||||||
|
- [x] 2.2 Preserve argument parse failures on function-call content and add the shared guarded function invoker.
|
||||||
|
- [x] 2.3 Apply guarded function invocation to the summary and workflow-editor agent pipelines.
|
||||||
|
|
||||||
|
## 3. Verification
|
||||||
|
|
||||||
|
- [x] 3.1 Refactor the touched response and invocation paths for DRYness, SOLID boundaries, and simplicity while preserving behavior.
|
||||||
|
- [x] 3.2 Run focused tests, the full solution tests, and strict OpenSpec validation.
|
||||||
|
- [x] 3.3 Verify the client behavior against the deployed LiteLLM Responses endpoint and restart Meeting Assistant only after confirming it is idle.
|
||||||
|
|
||||||
|
## 4. Responses message-item compatibility
|
||||||
|
|
||||||
|
- [x] 4.1 Add a failing client behavior test proving an outbound chat message retains the `message` discriminator through SDK serialization.
|
||||||
|
- [x] 4.2 Emit the Responses `message` discriminator for chat message input items.
|
||||||
|
- [x] 4.3 Run focused and full tests, validate OpenSpec strictly, restart while idle, and retry the failed summary through the local API.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-03
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The Windows microphone source currently creates one NAudio `IWaveIn` for the lifetime of a recording. When the endpoint is unplugged, NAudio reports a WASAPI exception through `RecordingStopped`; the source completes exceptionally, the composite source treats that as fatal, and `MeetingRecordingCoordinator` ends the run. The composite source already tolerates a temporarily quiet microphone by mixing system audio with synthetic silence after its alignment timeout, so recovery can be isolated to the microphone side.
|
||||||
|
|
||||||
|
The microphone selection provider already re-enumerates active endpoints whenever it creates a capture. Its selection rules ignore an unavailable runtime/configured device and fall back to the current Windows default. The missing behavior is retrying that resolution after an active capture fails.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Keep the active meeting run alive when microphone capture fails or stops unexpectedly.
|
||||||
|
- Re-resolve the effective microphone on every recovery attempt so another active endpoint can take over.
|
||||||
|
- Keep system-loopback audio flowing while microphone recovery is pending.
|
||||||
|
- Verify recovery deterministically through the public audio-source contract without physical audio devices.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Recover system-loopback capture failures.
|
||||||
|
- Persist or change the user's runtime microphone selection.
|
||||||
|
- Add UI, endpoint, or configuration controls for recovery.
|
||||||
|
- Splice or manufacture microphone audio for the disconnected interval.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Keep retry orchestration outside the NAudio adapter
|
||||||
|
|
||||||
|
`MicrophoneAudioSource` will own a recovery loop and ask `IMicrophoneDeviceProvider` for a new capture source on each attempt. The Windows provider will continue to own endpoint enumeration and selection, while an NAudio-specific adapter will own one `IWaveIn` lifetime.
|
||||||
|
|
||||||
|
This keeps device selection and WASAPI details behind a narrow boundary and makes the observable recovery behavior testable with deterministic capture sources. Retrying the same `IWaveIn` instance was rejected because a disconnected WASAPI client is not a reliable basis for endpoint failover.
|
||||||
|
|
||||||
|
### Treat unexpected completion and capture exceptions as recoverable
|
||||||
|
|
||||||
|
While the recording cancellation token remains active, microphone-source creation failures, capture exceptions, and clean-but-unexpected capture completion will all trigger another attempt. Cancellation remains the only normal terminal condition for the microphone stream.
|
||||||
|
|
||||||
|
This deliberately contains microphone failures without changing the composite source's handling of system-audio failures.
|
||||||
|
|
||||||
|
### Re-resolve after a bounded delay
|
||||||
|
|
||||||
|
Each recovery attempt will call the provider again after a short fixed delay. Recreating through the provider re-enumerates active devices and applies the existing runtime selection, configured selection, and Windows-default fallback rules. The delay prevents a busy loop while Windows is still updating endpoint state.
|
||||||
|
|
||||||
|
No new setting is introduced because recovery timing is an internal reliability detail and does not need user tuning for the current scope.
|
||||||
|
|
||||||
|
### Reuse the composite source's missing-stream behavior
|
||||||
|
|
||||||
|
The recovering microphone enumerable remains active between attempts instead of completing. The independently pumped system source therefore continues writing chunks, and the composite source's existing alignment timeout mixes those chunks with silent microphone samples until real microphone chunks resume.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Windows endpoint enumeration can lag behind physical disconnects** → Retry through fresh provider calls until the device list and default endpoint stabilize.
|
||||||
|
- **A persistent microphone or driver failure can retry indefinitely** → Use a delay, log each failed attempt, and stop immediately when the recording is canceled.
|
||||||
|
- **The replacement endpoint can have different native capabilities** → Continue requesting the run's configured PCM format through the same NAudio adapter; failed formats remain recoverable and retryable.
|
||||||
|
- **There is an unavoidable microphone gap during failover** → Preserve the meeting and system audio rather than inventing microphone samples; the mixed stream contains silence for the missing microphone interval.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
No data or configuration migration is required. Deploy the updated executable normally. Rollback consists of restoring the previous executable; existing meeting artifacts are unaffected.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None for this change.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Unplugging the active microphone currently propagates a WASAPI capture error through the recording pipeline and terminates the active meeting recording. Recording must remain available through transient device changes so that already-captured meeting work and continued system audio are not lost.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Recover microphone capture when the active Windows capture endpoint disappears or otherwise stops unexpectedly.
|
||||||
|
- Re-resolve the effective microphone for each recovery attempt so an available configured, runtime-selected, default, or fallback endpoint can take over.
|
||||||
|
- Keep the active recording and its independent system-audio capture alive while no microphone is temporarily available.
|
||||||
|
- Log microphone recovery failures and successful capture restarts without terminating the meeting run.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `meeting-recording`: Active recording becomes resilient to microphone endpoint disconnection and automatically resumes microphone capture from an available endpoint.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affects the Windows microphone capture source and device-provider boundary.
|
||||||
|
- Adds behavior tests around the public meeting audio-source contract.
|
||||||
|
- Does not change recording endpoints, tray controls, system-loopback capture, or transcription-provider APIs.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Recording mode captures microphone and computer output
|
||||||
|
Meeting Assistant SHALL capture microphone input and computer output and combine them into one audio stream for transcription.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL capture audio as 16 kHz mono PCM chunks for the existing recording and transcription pipeline.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL capture microphone and system loopback as separate input streams before producing the final mono chunks.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL clean the microphone stream with a local acoustic echo cancellation stage that uses system loopback as the far-end reference.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL produce final mono chunks by adding the cleaned microphone samples and system samples.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL align microphone and system samples through per-source buffers before mixing and SHALL NOT emit normal live audio chunks that contain only one source while the other source is merely delayed.
|
||||||
|
|
||||||
|
When one source stays quiet beyond the alignment timeout, Meeting Assistant SHALL mix the available source with synthetic silence for the missing source instead of blocking transcription.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL allow the final microphone/system mono mix to apply configurable microphone and system gain before combining samples.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL use the active run or launch profile recording options when configuring capture format and final microphone/system gains.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL clamp mixed samples after gain is applied.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL write only the mixed stream to the temporary WAV used by transcription and finalization.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL allow `Recording:MicrophoneDeviceId` to select a Windows microphone capture endpoint.
|
||||||
|
|
||||||
|
When `Recording:MicrophoneDeviceId` is blank or absent, Meeting Assistant SHALL use the Windows default capture endpoint.
|
||||||
|
|
||||||
|
When a microphone is selected from the tray icon menu, Meeting Assistant SHALL use that selected microphone for later recording starts until another microphone is selected or the process exits.
|
||||||
|
|
||||||
|
The tray icon right-click menu SHALL expose a `Microphone` submenu listing active microphone capture endpoints.
|
||||||
|
|
||||||
|
The `Microphone` submenu SHALL mark exactly one effective microphone as checked.
|
||||||
|
|
||||||
|
When no runtime microphone override is selected, the checked microphone SHALL be the configured microphone when it is available, otherwise the Windows default capture endpoint.
|
||||||
|
|
||||||
|
When the active microphone endpoint disappears, microphone capture fails, or microphone capture stops unexpectedly while a meeting recording is active, Meeting Assistant SHALL keep the meeting recording active and SHALL repeatedly re-resolve and restart microphone capture until capture succeeds or the recording is stopped.
|
||||||
|
|
||||||
|
Each microphone recovery attempt SHALL re-enumerate active microphone endpoints and apply the existing runtime-selected, configured, and Windows-default selection rules so an available endpoint can take over.
|
||||||
|
|
||||||
|
While microphone recovery is pending, Meeting Assistant SHALL keep system-loopback capture active and SHALL continue producing mixed audio with synthetic silence for the missing microphone stream.
|
||||||
|
|
||||||
|
#### Scenario: Both sources produce audio
|
||||||
|
- **WHEN** microphone and computer output audio chunks are available
|
||||||
|
- **THEN** Meeting Assistant mixes them into one PCM stream before transcription
|
||||||
|
|
||||||
|
#### Scenario: Mixed audio uses cleaned microphone and system audio
|
||||||
|
- **GIVEN** the echo canceller cleans a microphone chunk to sample `2000`
|
||||||
|
- **AND** the matching system chunk has sample `10000`
|
||||||
|
- **WHEN** microphone and system chunks are mixed with gains `1` and `1`
|
||||||
|
- **THEN** the mixed sample is `12000`
|
||||||
|
|
||||||
|
#### Scenario: Temporary recording stores only the mixed stream
|
||||||
|
- **GIVEN** Meeting Assistant has mixed microphone and system audio into one PCM chunk
|
||||||
|
- **WHEN** Meeting Assistant appends the chunk to the temporary recording
|
||||||
|
- **THEN** the main temporary WAV contains the mixed PCM
|
||||||
|
- **AND** no microphone or system sidecar WAV is written
|
||||||
|
|
||||||
|
#### Scenario: Launch profile recording options configure capture and gains
|
||||||
|
- **GIVEN** an active launch profile configures sample format and microphone/system mix gains
|
||||||
|
- **WHEN** Meeting Assistant captures and mixes audio for that run
|
||||||
|
- **THEN** the microphone and system capture sources receive that launch profile recording configuration
|
||||||
|
- **AND** the mixed output uses that launch profile's microphone/system gains
|
||||||
|
|
||||||
|
#### Scenario: Delayed sources are buffered before mixing
|
||||||
|
- **GIVEN** microphone audio arrives before matching system audio
|
||||||
|
- **WHEN** matching system audio arrives after a short delay
|
||||||
|
- **THEN** Meeting Assistant emits one mixed chunk for the aligned samples
|
||||||
|
- **AND** it does not emit separate microphone-only and system-only chunks for that delayed pair
|
||||||
|
|
||||||
|
#### Scenario: Quiet system audio does not block microphone transcription
|
||||||
|
- **GIVEN** microphone audio arrives
|
||||||
|
- **AND** system loopback audio does not arrive within the alignment timeout
|
||||||
|
- **WHEN** Meeting Assistant mixes the available audio
|
||||||
|
- **THEN** it emits the microphone audio mixed with silent system audio
|
||||||
|
- **AND** live transcription can continue while system loopback is quiet
|
||||||
|
|
||||||
|
#### Scenario: Continuous microphone audio does not suppress the alignment timeout
|
||||||
|
- **GIVEN** microphone audio keeps arriving
|
||||||
|
- **AND** system loopback audio stays unavailable past the alignment timeout
|
||||||
|
- **WHEN** Meeting Assistant checks the buffered microphone audio
|
||||||
|
- **THEN** it emits the buffered microphone audio mixed with silent system audio
|
||||||
|
- **AND** it does not wait indefinitely for a loopback chunk
|
||||||
|
|
||||||
|
#### Scenario: Device-level capture cannot be verified in tests
|
||||||
|
- **WHEN** automated tests run without live audio devices
|
||||||
|
- **THEN** Meeting Assistant verifies the audio mixer through deterministic source abstractions rather than depending on physical microphone or speaker devices
|
||||||
|
|
||||||
|
#### Scenario: Configured microphone is used for capture
|
||||||
|
- **GIVEN** `Recording:MicrophoneDeviceId` identifies an active microphone endpoint
|
||||||
|
- **WHEN** Meeting Assistant starts microphone capture
|
||||||
|
- **THEN** it captures from that endpoint
|
||||||
|
|
||||||
|
#### Scenario: Blank microphone setting uses Windows default
|
||||||
|
- **GIVEN** `Recording:MicrophoneDeviceId` is blank
|
||||||
|
- **WHEN** Meeting Assistant starts microphone capture
|
||||||
|
- **THEN** it captures from the Windows default capture endpoint
|
||||||
|
|
||||||
|
#### Scenario: Tray menu lists microphones with current selection checked
|
||||||
|
- **GIVEN** active microphone endpoints `integrated microphone` and `other microphone`
|
||||||
|
- **AND** `integrated microphone` is the effective microphone
|
||||||
|
- **WHEN** the taskbar menu is opened
|
||||||
|
- **THEN** it shows a `Microphone` submenu
|
||||||
|
- **AND** the `integrated microphone` item is checked
|
||||||
|
- **AND** the `other microphone` item is unchecked
|
||||||
|
|
||||||
|
#### Scenario: Tray microphone selection changes later capture
|
||||||
|
- **GIVEN** active microphone endpoints `integrated microphone` and `other microphone`
|
||||||
|
- **WHEN** the user selects `other microphone` from the taskbar microphone submenu
|
||||||
|
- **THEN** later recording starts capture from `other microphone`
|
||||||
|
|
||||||
|
#### Scenario: Disconnected microphone fails over during recording
|
||||||
|
- **GIVEN** a meeting is actively recording from one microphone and another microphone is available
|
||||||
|
- **WHEN** the active microphone is disconnected and its capture fails
|
||||||
|
- **THEN** the meeting recording remains active
|
||||||
|
- **AND** Meeting Assistant re-resolves the effective microphone and resumes capture from the available microphone
|
||||||
|
|
||||||
|
#### Scenario: Recording continues while no microphone is available
|
||||||
|
- **GIVEN** a meeting is actively recording
|
||||||
|
- **WHEN** the active microphone disconnects and no microphone is temporarily available
|
||||||
|
- **THEN** Meeting Assistant keeps the meeting recording and system-loopback capture active
|
||||||
|
- **AND** emits system audio mixed with synthetic microphone silence
|
||||||
|
- **WHEN** a microphone becomes available
|
||||||
|
- **THEN** Meeting Assistant resumes microphone capture for the same meeting run
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
## 1. Microphone recovery behavior
|
||||||
|
|
||||||
|
- [x] 1.1 Add a failing behavior test proving active microphone capture moves to a newly resolved capture source after the current source fails.
|
||||||
|
- [x] 1.2 Refactor the microphone device-provider boundary so recovery orchestration is platform-independent and individual NAudio capture lifetimes remain Windows-specific.
|
||||||
|
- [x] 1.3 Implement bounded-delay microphone recovery that re-resolves devices after creation failures, capture failures, and unexpected capture completion until recording cancellation.
|
||||||
|
- [x] 1.4 Add coverage proving capture recovers when no microphone is initially available and a later resolution succeeds.
|
||||||
|
- [x] 1.5 Add a failing selection test and fall back to an active endpoint when the selected and Windows-default endpoints are unavailable.
|
||||||
|
|
||||||
|
## 2. Verification
|
||||||
|
|
||||||
|
- [x] 2.1 Refactor the touched capture path for DRYness, SOLID boundaries, and KISS while preserving behavior.
|
||||||
|
- [x] 2.2 Run the focused microphone-selection and audio-source behavior tests plus the Windows application build.
|
||||||
|
- [x] 2.3 Run the full solution test suite and `openspec validate recover-microphone-disconnect --strict`.
|
||||||
|
- [x] 2.4 Verify the local health and recording-status surfaces without interrupting an active meeting run.
|
||||||
@@ -151,3 +151,38 @@ The built-in instructions SHALL direct the summary agent to append unexpected pr
|
|||||||
- **THEN** its instructions direct it to append a concise record to the meeting's assistant context
|
- **THEN** its instructions direct it to append a concise record to the meeting's assistant context
|
||||||
- **AND** later agents can discover that record when working on the same meeting
|
- **AND** later agents can discover that record when working on the same meeting
|
||||||
|
|
||||||
|
### Requirement: Summary agents tolerate Responses event streams and malformed tool arguments
|
||||||
|
Meeting Assistant SHALL consume successful summary-agent Responses results through the supported OpenAI Responses and Agent Framework Server-Sent Events adapter.
|
||||||
|
|
||||||
|
When a Responses event stream delivers completed output items separately from the final response metadata, Meeting Assistant SHALL assemble those output items into one agent response while preserving final response metadata and usage.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL provide an agent setting that selects streaming or non-streaming Responses transport. Streaming SHALL be enabled by default. When streaming is disabled, Meeting Assistant SHALL use the supported non-streaming OpenAI Responses client and adapter.
|
||||||
|
|
||||||
|
When a returned function call contains arguments that are not a valid JSON object, Meeting Assistant SHALL NOT invoke the requested function and SHALL return an invalid-tool-arguments result associated with the original call ID to the agent so it can correct the call.
|
||||||
|
|
||||||
|
When Meeting Assistant translates chat messages into a Responses request, every message input item SHALL retain the `message` item discriminator required by the OpenAI SDK and Responses API.
|
||||||
|
|
||||||
|
#### Scenario: Streamed text response is assembled
|
||||||
|
- **WHEN** the configured Responses endpoint returns completed message output in Server-Sent Events followed by final response metadata
|
||||||
|
- **THEN** the summary agent receives the completed message text, response metadata, and usage without a JSON document parse failure
|
||||||
|
|
||||||
|
#### Scenario: Streamed function call is assembled
|
||||||
|
- **WHEN** the configured Responses endpoint returns a completed function-call output item in Server-Sent Events
|
||||||
|
- **THEN** the summary agent receives the function call with its call ID, function name, and parsed arguments
|
||||||
|
|
||||||
|
#### Scenario: Streaming transport can be disabled
|
||||||
|
- **WHEN** `MeetingAssistant:Agent:UseStreaming` is `false`
|
||||||
|
- **THEN** the summary agent requests a non-streaming Responses result
|
||||||
|
- **AND** converts the response through the supported OpenAI Responses adapter
|
||||||
|
|
||||||
|
#### Scenario: Chat message input remains a Responses message
|
||||||
|
- **WHEN** the summary agent sends a user or assistant chat message through the Responses client
|
||||||
|
- **THEN** the outbound Responses input item has type `message`
|
||||||
|
- **AND** the request does not contain an `unknown` input item type
|
||||||
|
|
||||||
|
#### Scenario: Malformed function arguments are returned to the agent
|
||||||
|
- **WHEN** the model returns a function call whose arguments are not a valid JSON object
|
||||||
|
- **THEN** Meeting Assistant does not invoke the requested function
|
||||||
|
- **AND** sends a function result with an invalid-tool-arguments error for the original call ID back to the agent
|
||||||
|
- **AND** allows the agent loop to continue
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user