AI-Studio/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallAccumulator.cs
Thorsten Sommer c95cc5bacc
Some checks failed
Build and Release / Determine run mode (push) Has been cancelled
Build and Release / Verify (push) Has been cancelled
Build and Release / Read metadata (push) Has been cancelled
Build and Release / Sync Flatpak repo (push) Has been cancelled
Build and Release / Collect Flatpak artifacts (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Prepare & create release (push) Has been cancelled
Build and Release / Publish release (push) Has been cancelled
Stream the answers again whenever tools are in play (#986)
2026-09-20 10:44:33 +02:00

227 lines
9.0 KiB
C#

using System.Text;
using System.Text.Json;
namespace AIStudio.Provider.OpenAI;
/// <summary>
/// Reads a streamed Chat Completions answer back into the message the tool calling loop works with.
/// </summary>
/// <remarks>
/// This one path serves seventeen providers, which is why every correlation here is staggered
/// rather than assumed: a call is found by its index, failing that by its ID, failing that it is
/// the one most recently opened. Gateways differ in all of these, and in whether they close the
/// stream with a "[DONE]" at all.<br/><br/>
/// No HTTP, no dependency injection, no provider: what happens here are decisions about bytes,
/// and those are the decisions worth having a test for.
/// </remarks>
/// <param name="readSources">
/// Reads the sources out of one line, in whichever shape this provider sends them. Left out, the
/// round runs without sources, which is what a provider that sends none needs.
/// </param>
public sealed class ChatCompletionToolCallAccumulator(Func<ServerSentEvent, IList<ISource>>? readSources = null)
{
private const string DONE = "[DONE]";
private const string EMPTY_ARGUMENTS = "{}";
private readonly StringBuilder text = new();
private readonly StringBuilder reasoning = new();
private readonly List<ToolCallBuilder> toolCalls = [];
private readonly Dictionary<int, ToolCallBuilder> toolCallsByIndex = [];
private bool hasReadAnything;
/// <summary>
/// Takes the next event of the stream and returns what it has to show.
/// </summary>
/// <param name="serverSentEvent">The event to read.</param>
/// <returns>The text of this event, empty when it carried none.</returns>
public ChatCompletionStreamPart Process(ServerSentEvent serverSentEvent)
{
if (serverSentEvent.Data.Length is 0 || serverSentEvent.Data is DONE)
return ChatCompletionStreamPart.Nothing;
ChatCompletionToolStreamLine? line;
try
{
line = JsonSerializer.Deserialize<ChatCompletionToolStreamLine>(serverSentEvent.Data, ProviderJsonOptions.OPTIONS);
}
catch (JsonException)
{
// A line we cannot read is a line we skip, exactly as the plain text path does:
return ChatCompletionStreamPart.Nothing;
}
//
// Only the first choice is ever used, here as much as on the plain text path: we never
// ask for more than one, and a provider which sends more has no say in which one counts.
//
//
// Sources are read off the same line, through the provider's own types: they may sit on
// a line of their own or right next to the text, and a line without any gives an empty
// list either way.
//
var sources = readSources?.Invoke(serverSentEvent) ?? [];
var delta = line?.Choices?.FirstOrDefault()?.Delta;
if (delta is null)
return WithSources(string.Empty, sources);
this.hasReadAnything = true;
if (!string.IsNullOrEmpty(delta.ReasoningContent))
this.reasoning.Append(delta.ReasoningContent);
foreach (var toolCallDelta in delta.ToolCalls ?? [])
{
if (toolCallDelta is null)
continue;
this.Apply(toolCallDelta);
}
var textDelta = delta.Content;
if (textDelta.Length is 0)
return WithSources(string.Empty, sources);
this.text.Append(textDelta);
return new ChatCompletionStreamPart(textDelta, sources);
}
/// <summary>
/// Builds the message of the round from everything the stream said.
/// </summary>
/// <returns>
/// The message, or null when no line of the stream was readable at all. Null is how a failed
/// request looks from here, and it ends the round.
/// </returns>
/// <remarks>
/// The end of the stream is the end of the message. There is nothing else to wait for: a
/// "[DONE]" is not sent by every gateway, and a finish reason not by every one either.
/// </remarks>
public ChatCompletionResponseMessage? Build()
{
if (!this.hasReadAnything)
return null;
var answer = this.text.ToString();
return new ChatCompletionResponseMessage
{
Role = "assistant",
//
// No text means no content field, the way a round which only calls a tool arrives
// when it is not streamed. Some providers reject an empty string in its place.
//
RawContent = answer.Length is 0 ? null : JsonSerializer.SerializeToElement(answer),
ReasoningContent = this.reasoning.Length is 0 ? null : this.reasoning.ToString(),
ToolCalls = this.toolCalls.Count is 0
? null
: this.toolCalls.Select(toolCall => (ChatCompletionToolCall?)toolCall.Build()).ToList(),
};
}
private void Apply(ChatCompletionToolCallDelta toolCallDelta)
{
var toolCall = this.Resolve(toolCallDelta);
//
// The first non-empty value wins for everything but the arguments: some providers repeat
// the ID and the name with every fragment, and a later empty one must not erase them.
//
toolCall.Id ??= Coalesce(toolCallDelta.Id);
toolCall.Type ??= Coalesce(toolCallDelta.Type);
toolCall.Name ??= Coalesce(toolCallDelta.Function?.Name);
// The arguments are the one thing that is always appended, because that is how they come:
if (!string.IsNullOrEmpty(toolCallDelta.Function?.Arguments))
toolCall.Arguments.Append(toolCallDelta.Function.Arguments);
}
/// <summary>
/// Finds the call a fragment belongs to, or opens a new one for it.
/// </summary>
private ToolCallBuilder Resolve(ChatCompletionToolCallDelta toolCallDelta)
{
//
// The index is what the specification correlates by, so it comes first:
//
if (toolCallDelta.Index is { } index)
{
if (this.toolCallsByIndex.TryGetValue(index, out var knownByIndex))
return knownByIndex;
var openedByIndex = this.Open();
this.toolCallsByIndex[index] = openedByIndex;
return openedByIndex;
}
//
// Some gateways leave the index out and correlate by ID instead:
//
if (!string.IsNullOrWhiteSpace(toolCallDelta.Id))
{
var knownById = this.toolCalls.FirstOrDefault(x => string.Equals(x.Id, toolCallDelta.Id, StringComparison.Ordinal));
if (knownById is not null)
return knownById;
return this.Open();
}
//
// And some send neither once the call is open, which leaves the one we opened last. A
// fragment before any call was opened opens one, rather than being dropped.
//
return this.toolCalls.Count > 0 ? this.toolCalls[^1] : this.Open();
}
private ToolCallBuilder Open()
{
var toolCall = new ToolCallBuilder();
this.toolCalls.Add(toolCall);
return toolCall;
}
private static string? Coalesce(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
/// <summary>
/// A part for a line which brought sources but no text, or nothing at all.
/// </summary>
private static ChatCompletionStreamPart WithSources(string text, IList<ISource> sources)
=> sources.Count is 0 ? ChatCompletionStreamPart.Nothing : new ChatCompletionStreamPart(text, sources);
/// <summary>
/// One tool call while its fragments are still arriving.
/// </summary>
private sealed class ToolCallBuilder
{
public string? Id { get; set; }
public string? Type { get; set; }
public string? Name { get; set; }
public StringBuilder Arguments { get; } = new();
/// <summary>
/// Builds the call in the shape a non-streamed answer would have carried it.
/// </summary>
/// <remarks>
/// A call without an ID, without a name, or with arguments which are not an object stays
/// as it is: the adapter has to see what the model actually sent, so that it can reject
/// the call the way an invalid one has to be rejected.<br/><br/>
/// Empty arguments are the one exception, and they are not a correction but a
/// translation: a tool which takes nothing gets no fragment at all here, while the same
/// call arrives as an empty object when it is not streamed. Handing on the empty string
/// would have every parameterless tool rejected as invalid.
/// </remarks>
public ChatCompletionToolCall Build() => new()
{
Id = this.Id,
Type = this.Type ?? "function",
Function = new ChatCompletionToolFunction
{
Name = this.Name,
Arguments = this.Arguments.Length is 0 ? EMPTY_ARGUMENTS : this.Arguments.ToString(),
},
};
}
}