AI-Studio/app/MindWork AI Studio/Provider/Anthropic/AnthropicMessageStreamAccumulator.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

151 lines
6.0 KiB
C#

using System.Text.Json;
namespace AIStudio.Provider.Anthropic;
/// <summary>
/// Reads a streamed Anthropic messages call back into the answer the tool calling loop works with.
/// </summary>
/// <remarks>
/// Anthropic streams a message as a set of content blocks which open, grow, and close, correlated
/// by their index and interleaved with one another. This type keeps one builder per index and
/// hands out text as it arrives; everything else is bookkeeping until the message ends.<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>
public sealed class AnthropicMessageStreamAccumulator
{
private const string EVENT_BLOCK_START = "content_block_start";
private const string EVENT_BLOCK_DELTA = "content_block_delta";
private const string EVENT_BLOCK_STOP = "content_block_stop";
private const string EVENT_MESSAGE_DELTA = "message_delta";
private const string EVENT_MESSAGE_STOP = "message_stop";
private const string DELTA_TEXT = "text_delta";
private readonly Dictionary<int, AnthropicContentBlockBuilder> openBlocks = [];
private readonly SortedDictionary<int, JsonElement> finishedBlocks = [];
private readonly Dictionary<string, string> unparsableToolArguments = [];
private string stopReason = string.Empty;
private bool messageEnded;
/// <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 AnthropicStreamPart Process(ServerSentEvent serverSentEvent)
{
if (serverSentEvent.Data.Length is 0)
return AnthropicStreamPart.Nothing;
AnthropicStreamLine line;
try
{
line = JsonSerializer.Deserialize<AnthropicStreamLine>(serverSentEvent.Data, ProviderJsonOptions.OPTIONS);
}
catch (JsonException)
{
// A line we cannot read is a line we skip, exactly as the plain text path does:
return AnthropicStreamPart.Nothing;
}
switch (line.Type)
{
case EVENT_BLOCK_START:
this.openBlocks[line.Index] = new AnthropicContentBlockBuilder(line.ContentBlock);
return AnthropicStreamPart.Nothing;
case EVENT_BLOCK_DELTA:
if (!this.openBlocks.TryGetValue(line.Index, out var openBlock))
{
//
// A delta for a block which never opened. Only text can be salvaged from
// that: a tool use without its ID and name is unanswerable, and thinking
// without its signature would have the next round refused. Text is kept as a
// block of its own so that what the user reads is what the model is told it
// said.
//
if (line.Delta.Type is not DELTA_TEXT)
return AnthropicStreamPart.Nothing;
openBlock = new AnthropicContentBlockBuilder(EmptyTextBlock());
this.openBlocks[line.Index] = openBlock;
}
return new AnthropicStreamPart(openBlock.Append(line.Delta));
case EVENT_BLOCK_STOP:
if (this.openBlocks.Remove(line.Index, out var finishedBlock))
this.Finish(line.Index, finishedBlock);
return AnthropicStreamPart.Nothing;
case EVENT_MESSAGE_DELTA:
//
// The stop reason ends the message as surely as the closing event does. Taking
// both means a gateway which sends only one of them still gets a round out.
//
if (!string.IsNullOrWhiteSpace(line.Delta.StopReason))
{
this.stopReason = line.Delta.StopReason;
this.messageEnded = true;
}
return AnthropicStreamPart.Nothing;
case EVENT_MESSAGE_STOP:
this.messageEnded = true;
this.MaterializeOpenBlocks();
return AnthropicStreamPart.Nothing;
default:
return AnthropicStreamPart.Nothing;
}
}
/// <summary>
/// Builds the answer of the round from everything the stream said.
/// </summary>
/// <returns>
/// The answer, or null when the stream ended before the message did. Null is how a failed
/// request and a stream cut off mid-sentence look from here, and both end the round.
/// </returns>
public AnthropicResponse? Build()
{
if (!this.messageEnded)
return null;
// Blocks whose closing event never came are finished here rather than dropped:
this.MaterializeOpenBlocks();
return new AnthropicResponse
{
StopReason = this.stopReason,
Content = [..this.finishedBlocks.Values],
UnparsableToolInputs = this.unparsableToolArguments,
};
}
private void MaterializeOpenBlocks()
{
foreach (var (index, builder) in this.openBlocks)
this.Finish(index, builder);
this.openBlocks.Clear();
}
private void Finish(int index, AnthropicContentBlockBuilder builder)
{
this.finishedBlocks[index] = builder.Build();
// Read after the block was built, because that is when the arguments are parsed:
if (builder.UnparsableToolArguments is not null && !string.IsNullOrWhiteSpace(builder.ToolUseId))
this.unparsableToolArguments[builder.ToolUseId] = builder.UnparsableToolArguments;
}
private static JsonElement EmptyTextBlock() => JsonSerializer.SerializeToElement(new
{
type = "text",
text = string.Empty,
});
}