mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-21 21:33:37 +00:00
Stream the answers again whenever tools are in play (#986)
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
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
This commit is contained in:
parent
459165f1be
commit
c95cc5bacc
@ -0,0 +1,227 @@
|
|||||||
|
using System.Buffers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace AIStudio.Provider.Anthropic;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Puts one streamed content block back together.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A block opens with a seed, grows through fragments, and has to end up as the very block the
|
||||||
|
/// provider would have sent had we not streamed: it goes back on the next request, and Anthropic
|
||||||
|
/// checks what it gets. A thinking block is the sharp edge here -- its signature has to return
|
||||||
|
/// byte for byte with the text it was made for, or the next round is refused with a 400.<br/><br/>
|
||||||
|
/// This is a pure function over bytes: no HTTP, no state beyond the block itself. That is what
|
||||||
|
/// makes it the piece worth testing against recorded streams.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AnthropicContentBlockBuilder
|
||||||
|
{
|
||||||
|
private const string TYPE_TEXT = "text";
|
||||||
|
private const string TYPE_TOOL_USE = "tool_use";
|
||||||
|
private const string TYPE_THINKING = "thinking";
|
||||||
|
|
||||||
|
private const string DELTA_TEXT = "text_delta";
|
||||||
|
private const string DELTA_INPUT_JSON = "input_json_delta";
|
||||||
|
private const string DELTA_THINKING = "thinking_delta";
|
||||||
|
private const string DELTA_SIGNATURE = "signature_delta";
|
||||||
|
|
||||||
|
private const string EMPTY_OBJECT = "{}";
|
||||||
|
|
||||||
|
private readonly JsonElement seed;
|
||||||
|
private readonly StringBuilder text = new();
|
||||||
|
private readonly StringBuilder toolArguments = new();
|
||||||
|
private readonly StringBuilder thinking = new();
|
||||||
|
private string signature;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a block from the seed the provider sent for it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="contentBlock">The block as it opened.</param>
|
||||||
|
public AnthropicContentBlockBuilder(JsonElement contentBlock)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// The seed is cloned because the document it was read from is gone by the time this block
|
||||||
|
// is built, and an element which outlives its document reads memory that is no longer
|
||||||
|
// there.
|
||||||
|
//
|
||||||
|
this.seed = contentBlock.ValueKind is JsonValueKind.Object ? contentBlock.Clone() : default;
|
||||||
|
this.BlockType = ReadString(this.seed, "type");
|
||||||
|
|
||||||
|
//
|
||||||
|
// Anthropic seeds a block with what it already has, which is usually nothing. When it is
|
||||||
|
// not nothing, it belongs in front of everything that follows.
|
||||||
|
//
|
||||||
|
this.text.Append(ReadString(this.seed, TYPE_TEXT));
|
||||||
|
this.thinking.Append(ReadString(this.seed, TYPE_THINKING));
|
||||||
|
this.signature = ReadString(this.seed, "signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What kind of block this is: text, a tool use, thinking, or something we do not know.
|
||||||
|
/// </summary>
|
||||||
|
public string BlockType { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ID of the tool use, for a tool use block.
|
||||||
|
/// </summary>
|
||||||
|
public string ToolUseId => ReadString(this.seed, "id");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The tool arguments as they came off the wire, set only when they never parsed into an object.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The block itself carries an empty object then, because that is what may go back to the
|
||||||
|
/// provider. The call still has to be rejected rather than run with no arguments at all,
|
||||||
|
/// which is what this text is for.
|
||||||
|
/// </remarks>
|
||||||
|
public string? UnparsableToolArguments { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the next piece of this block.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="delta">The piece as it arrived.</param>
|
||||||
|
/// <returns>The text to show, empty for every piece which is not text.</returns>
|
||||||
|
public string Append(AnthropicStreamDelta delta)
|
||||||
|
{
|
||||||
|
switch (delta.Type)
|
||||||
|
{
|
||||||
|
case DELTA_TEXT when delta.Text is not null:
|
||||||
|
this.text.Append(delta.Text);
|
||||||
|
return delta.Text;
|
||||||
|
|
||||||
|
case DELTA_INPUT_JSON when delta.PartialJson is not null:
|
||||||
|
this.toolArguments.Append(delta.PartialJson);
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
case DELTA_THINKING when delta.Thinking is not null:
|
||||||
|
this.thinking.Append(delta.Thinking);
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
case DELTA_SIGNATURE when delta.Signature is not null:
|
||||||
|
this.signature = delta.Signature;
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the finished block, in the shape a non-streamed call would have returned it.
|
||||||
|
/// </summary>
|
||||||
|
public JsonElement Build()
|
||||||
|
{
|
||||||
|
switch (this.BlockType)
|
||||||
|
{
|
||||||
|
case TYPE_TEXT:
|
||||||
|
return this.BuildFromSeed(new()
|
||||||
|
{
|
||||||
|
["type"] = JsonSerializer.Serialize(TYPE_TEXT),
|
||||||
|
["text"] = JsonSerializer.Serialize(this.text.ToString()),
|
||||||
|
});
|
||||||
|
|
||||||
|
case TYPE_THINKING:
|
||||||
|
//
|
||||||
|
// The signature travels with the thinking it belongs to. Anthropic refuses the
|
||||||
|
// next round without it, so it is written even when it stayed empty: a missing
|
||||||
|
// field and an empty one fail the same way, and the empty one says where to look.
|
||||||
|
//
|
||||||
|
return this.BuildFromSeed(new()
|
||||||
|
{
|
||||||
|
["type"] = JsonSerializer.Serialize(TYPE_THINKING),
|
||||||
|
["thinking"] = JsonSerializer.Serialize(this.thinking.ToString()),
|
||||||
|
["signature"] = JsonSerializer.Serialize(this.signature),
|
||||||
|
});
|
||||||
|
|
||||||
|
case TYPE_TOOL_USE:
|
||||||
|
return this.BuildFromSeed(new()
|
||||||
|
{
|
||||||
|
["input"] = this.BuildToolInput(),
|
||||||
|
});
|
||||||
|
|
||||||
|
default:
|
||||||
|
//
|
||||||
|
// Redacted thinking and anything we have not seen before go back untouched. We
|
||||||
|
// cannot read them, which is precisely why we must not rewrite them either.
|
||||||
|
//
|
||||||
|
return this.seed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The tool arguments as the JSON object they have to be.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A tool without arguments gets no fragment at all, so an empty buffer is an empty object.
|
||||||
|
/// A buffer which is not an object is kept aside instead: the block needs something the
|
||||||
|
/// provider accepts, while the call needs the text that made it invalid.
|
||||||
|
/// </remarks>
|
||||||
|
private string BuildToolInput()
|
||||||
|
{
|
||||||
|
var arguments = this.toolArguments.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(arguments))
|
||||||
|
return EMPTY_OBJECT;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(arguments);
|
||||||
|
if (document.RootElement.ValueKind is JsonValueKind.Object)
|
||||||
|
return arguments;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Falls through to the same place a well-formed non-object does:
|
||||||
|
}
|
||||||
|
|
||||||
|
this.UnparsableToolArguments = arguments;
|
||||||
|
return EMPTY_OBJECT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the given properties over a copy of the seed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Copying rather than rebuilding keeps whatever the provider sent along that we do not know
|
||||||
|
/// about. The values are JSON text, so that a string is escaped exactly once.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="overrides">The properties to write, as property name to JSON text.</param>
|
||||||
|
private JsonElement BuildFromSeed(Dictionary<string, string> overrides)
|
||||||
|
{
|
||||||
|
var buffer = new ArrayBufferWriter<byte>();
|
||||||
|
using (var writer = new Utf8JsonWriter(buffer))
|
||||||
|
{
|
||||||
|
writer.WriteStartObject();
|
||||||
|
if (this.seed.ValueKind is JsonValueKind.Object)
|
||||||
|
foreach (var property in this.seed.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (overrides.ContainsKey(property.Name))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
property.WriteTo(writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (propertyName, json) in overrides)
|
||||||
|
{
|
||||||
|
writer.WritePropertyName(propertyName);
|
||||||
|
using var value = JsonDocument.Parse(json);
|
||||||
|
value.RootElement.WriteTo(writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(buffer.WrittenMemory);
|
||||||
|
return document.RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadString(JsonElement item, string propertyName)
|
||||||
|
{
|
||||||
|
if (item.ValueKind is not JsonValueKind.Object ||
|
||||||
|
!item.TryGetProperty(propertyName, out var property) ||
|
||||||
|
property.ValueKind is not JsonValueKind.String)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
return property.GetString() ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,151 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -11,6 +11,15 @@ public sealed record AnthropicResponse
|
|||||||
|
|
||||||
public IList<JsonElement> Content { get; init; } = [];
|
public IList<JsonElement> Content { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The argument text of those tool uses whose arguments never parsed, by tool use ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Empty for a non-streamed answer, where the arguments either arrived as an object or did
|
||||||
|
/// not arrive at all.
|
||||||
|
/// </remarks>
|
||||||
|
public IReadOnlyDictionary<string, string> UnparsableToolInputs { get; init; } = new Dictionary<string, string>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The tool calls the model asked for.
|
/// The tool calls the model asked for.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -25,6 +34,7 @@ public sealed record AnthropicResponse
|
|||||||
Id = ReadString(x, "id"),
|
Id = ReadString(x, "id"),
|
||||||
Name = ReadString(x, "name"),
|
Name = ReadString(x, "name"),
|
||||||
Input = x.TryGetProperty("input", out var input) ? input : default,
|
Input = x.TryGetProperty("input", out var input) ? input : default,
|
||||||
|
UnparsableArguments = this.UnparsableToolInputs.GetValueOrDefault(ReadString(x, "id")),
|
||||||
})
|
})
|
||||||
.Where(x => !string.IsNullOrWhiteSpace(x.Id) && !string.IsNullOrWhiteSpace(x.Name))
|
.Where(x => !string.IsNullOrWhiteSpace(x.Id) && !string.IsNullOrWhiteSpace(x.Name))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
namespace AIStudio.Provider.Anthropic;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One piece of a streamed content block.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Which of the fields is set depends on what the block is made of: text arrives as text, tool
|
||||||
|
/// arguments as fragments of JSON, and a thinking block brings its signature in one piece at the
|
||||||
|
/// end. The stop reason belongs to the message rather than to a block, and shares this shape
|
||||||
|
/// because the API sends it in a delta of its own.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Type">What kind of piece this is.</param>
|
||||||
|
/// <param name="Text">The piece of text, for a text delta.</param>
|
||||||
|
/// <param name="PartialJson">The fragment of the tool arguments, for an input JSON delta.</param>
|
||||||
|
/// <param name="Thinking">The piece of thinking, for a thinking delta.</param>
|
||||||
|
/// <param name="Signature">The signature of a thinking block, for a signature delta.</param>
|
||||||
|
/// <param name="StopReason">Why the model stopped, for the message delta.</param>
|
||||||
|
public readonly record struct AnthropicStreamDelta(string? Type, string? Text, string? PartialJson, string? Thinking, string? Signature, string? StopReason);
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace AIStudio.Provider.Anthropic;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One line of a streamed Anthropic messages call.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Type">The kind of event this line reports.</param>
|
||||||
|
/// <param name="Index">Which content block the event belongs to; blocks are correlated by it.</param>
|
||||||
|
/// <param name="ContentBlock">The block as it opens, for a content block start.</param>
|
||||||
|
/// <param name="Delta">The piece this event adds, for a content block delta or a message delta.</param>
|
||||||
|
public readonly record struct AnthropicStreamLine(string? Type, int Index, JsonElement ContentBlock, AnthropicStreamDelta Delta);
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
namespace AIStudio.Provider.Anthropic;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What one line of a streamed Anthropic messages call has to show to the user.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only text ever shows. Thinking does not: neither of the two paths has ever put it on screen,
|
||||||
|
/// and doing so would be a feature of its own rather than a side effect of streaming.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="TextDelta">The text this line carried, empty when it carried none.</param>
|
||||||
|
public readonly record struct AnthropicStreamPart(string TextDelta)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The part of a line that says nothing to the user, such as an opening or closing block.
|
||||||
|
/// </summary>
|
||||||
|
public static AnthropicStreamPart Nothing => new(string.Empty);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this part has anything to show at all.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasContent => this.TextDelta.Length > 0;
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
using AIStudio.Tools.ToolCallingSystem;
|
using AIStudio.Tools.ToolCallingSystem;
|
||||||
using AIStudio.Tools.ToolCallingSystem.Harness;
|
using AIStudio.Tools.ToolCallingSystem.Harness;
|
||||||
|
|
||||||
@ -15,7 +17,7 @@ namespace AIStudio.Provider.Anthropic;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageBase> baseMessages, string systemPrompt, int maxTokens,
|
public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageBase> baseMessages, string systemPrompt, int maxTokens,
|
||||||
IDictionary<string, object> apiParameters, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
|
IDictionary<string, object> apiParameters, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
|
||||||
Func<ChatRequest, CancellationToken, Task<AnthropicResponse?>> executeRequestAsync) : IToolCallingProviderAdapter
|
Func<ChatRequest, CancellationToken, IAsyncEnumerable<ServerSentEvent>> streamRequestAsync) : IToolCallingProviderAdapter
|
||||||
{
|
{
|
||||||
private readonly List<IMessageBase> internalMessages = [];
|
private readonly List<IMessageBase> internalMessages = [];
|
||||||
private readonly List<AnthropicToolResultContent> pendingToolResults = [];
|
private readonly List<AnthropicToolResultContent> pendingToolResults = [];
|
||||||
@ -27,7 +29,7 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
|
|||||||
public IReadOnlyList<string> RecordedRequestTexts => this.recordedRequestTexts;
|
public IReadOnlyList<string> RecordedRequestTexts => this.recordedRequestTexts;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ToolCallingRound?> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default)
|
public async IAsyncEnumerable<ToolCallingStreamEvent> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, [EnumeratorCancellation] CancellationToken token = default)
|
||||||
{
|
{
|
||||||
//
|
//
|
||||||
// The results of the previous round are flushed here rather than when they were recorded:
|
// The results of the previous round are flushed here rather than when they were recorded:
|
||||||
@ -39,7 +41,7 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
|
|||||||
this.pendingToolResults.Clear();
|
this.pendingToolResults.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = await executeRequestAsync(new ChatRequest
|
var request = new ChatRequest
|
||||||
{
|
{
|
||||||
Model = chatModel.Id,
|
Model = chatModel.Id,
|
||||||
Messages = [..baseMessages, ..this.internalMessages],
|
Messages = [..baseMessages, ..this.internalMessages],
|
||||||
@ -48,16 +50,29 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
|
|||||||
: $"{systemPrompt}{Environment.NewLine}{Environment.NewLine}{finalResponseInstruction}",
|
: $"{systemPrompt}{Environment.NewLine}{Environment.NewLine}{finalResponseInstruction}",
|
||||||
|
|
||||||
MaxTokens = maxTokens,
|
MaxTokens = maxTokens,
|
||||||
Stream = false,
|
Stream = true,
|
||||||
Tools = includeTools && this.tools.Count > 0 ? this.tools : null,
|
Tools = includeTools && this.tools.Count > 0 ? this.tools : null,
|
||||||
AdditionalApiParameters = apiParameters,
|
AdditionalApiParameters = apiParameters,
|
||||||
}, token);
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// The text goes out while it is being written; the blocks are put back together behind
|
||||||
|
// it, because they have to return to the provider exactly as they arrived.
|
||||||
|
//
|
||||||
|
var accumulator = new AnthropicMessageStreamAccumulator();
|
||||||
|
await foreach (var serverSentEvent in streamRequestAsync(request, token))
|
||||||
|
{
|
||||||
|
var part = accumulator.Process(serverSentEvent);
|
||||||
|
if (part.HasContent)
|
||||||
|
yield return ToolCallingStreamEvent.TextDelta(part.TextDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = accumulator.Build();
|
||||||
if (response is null)
|
if (response is null)
|
||||||
return null;
|
yield break;
|
||||||
|
|
||||||
this.lastResponse = response;
|
this.lastResponse = response;
|
||||||
return new ToolCallingRound(
|
yield return ToolCallingStreamEvent.RoundCompleted(new ToolCallingRound(
|
||||||
response.GetTextOutput(),
|
response.GetTextOutput(),
|
||||||
response.GetToolUses()
|
response.GetToolUses()
|
||||||
.Select(toolUse => new ToolCallingRequestedCall(
|
.Select(toolUse => new ToolCallingRequestedCall(
|
||||||
@ -66,7 +81,7 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
|
|||||||
toolUse.Arguments,
|
toolUse.Arguments,
|
||||||
ToolExecutor.IsValidArgumentsJson(toolUse.Arguments)))
|
ToolExecutor.IsValidArgumentsJson(toolUse.Arguments)))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
[]);
|
[]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@ -10,8 +10,19 @@ public sealed record AnthropicToolUse
|
|||||||
|
|
||||||
public JsonElement Input { get; init; }
|
public JsonElement Input { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The arguments as they came off the wire, set only when they never parsed into an object.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only a streamed round can have these: the arguments arrive in fragments there, and a
|
||||||
|
/// stream which ends mid-fragment leaves text which is not an object. The block carries an
|
||||||
|
/// empty object in that case, because that is what may go back to the provider -- while the
|
||||||
|
/// call itself has to be rejected rather than run without the arguments it asked for.
|
||||||
|
/// </remarks>
|
||||||
|
public string? UnparsableArguments { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The arguments as JSON text, which is what the tool executor works with.
|
/// The arguments as JSON text, which is what the tool executor works with.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Arguments => this.Input.ValueKind is JsonValueKind.Undefined ? "{}" : this.Input.GetRawText();
|
public string Arguments => this.UnparsableArguments ?? (this.Input.ValueKind is JsonValueKind.Undefined ? "{}" : this.Input.GetRawText());
|
||||||
}
|
}
|
||||||
@ -75,8 +75,8 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n
|
|||||||
|
|
||||||
//
|
//
|
||||||
// Prepare the tools we want to use. When the model may call one, the conversation runs
|
// Prepare the tools we want to use. When the model may call one, the conversation runs
|
||||||
// through the harness instead of being streamed straight away: tool rounds are not
|
// through the harness instead of going straight to the streaming path below. It streams
|
||||||
// streamed, only the final answer is.
|
// there as well, round by round -- what the harness adds is the tools in between.
|
||||||
//
|
//
|
||||||
var toolRegistry = Program.SERVICE_PROVIDER.GetService<ToolRegistry>();
|
var toolRegistry = Program.SERVICE_PROVIDER.GetService<ToolRegistry>();
|
||||||
var toolExecutor = Program.SERVICE_PROVIDER.GetService<ToolExecutor>();
|
var toolExecutor = Program.SERVICE_PROVIDER.GetService<ToolExecutor>();
|
||||||
@ -93,7 +93,7 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n
|
|||||||
if (toolExecutor is not null && runnableTools.Count > 0)
|
if (toolExecutor is not null && runnableTools.Count > 0)
|
||||||
{
|
{
|
||||||
var adapter = new AnthropicToolCallingAdapter(chatModel, [..messages], systemPrompt, maxTokens, apiParameters, runnableTools,
|
var adapter = new AnthropicToolCallingAdapter(chatModel, [..messages], systemPrompt, maxTokens, apiParameters, runnableTools,
|
||||||
(requestDto, requestToken) => this.ExecuteMessagesRequest(requestDto, requestedSecret, requestToken));
|
(requestDto, requestToken) => this.StreamMessagesRequest(requestDto, requestedSecret, requestToken));
|
||||||
|
|
||||||
var loop = Program.SERVICE_PROVIDER.GetRequiredService<IToolCallingLoop>();
|
var loop = Program.SERVICE_PROVIDER.GetRequiredService<IToolCallingLoop>();
|
||||||
var loopContext = new ToolCallingLoopContext
|
var loopContext = new ToolCallingLoopContext
|
||||||
@ -151,30 +151,25 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs one non-streamed messages request, as the tool rounds need it.
|
/// Runs one round of a tool calling conversation against the messages API.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Tool rounds are not streamed: the whole answer has to be there before its tool calls can
|
/// Nothing but the HTTP request is done here. The retries, the timeouts, and the error
|
||||||
/// be executed. Only the final answer reaches the user through the streaming path.
|
/// classification come from the shared stream reader, which the tool rounds used to go
|
||||||
|
/// without; reading the events is the adapter's business.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <returns>The answer, or null when the request failed and the user was already told.</returns>
|
private IAsyncEnumerable<ServerSentEvent> StreamMessagesRequest(ChatRequest requestDto, RequestedSecret requestedSecret, CancellationToken token)
|
||||||
private async Task<AnthropicResponse?> ExecuteMessagesRequest(ChatRequest requestDto, RequestedSecret requestedSecret, CancellationToken token)
|
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, "messages");
|
async Task<HttpRequestMessage> RequestBuilder()
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "messages");
|
||||||
request.Headers.Add("x-api-key", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION));
|
request.Headers.Add("x-api-key", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION));
|
||||||
request.Headers.Add("anthropic-version", "2023-06-01");
|
request.Headers.Add("anthropic-version", "2023-06-01");
|
||||||
request.Content = new StringContent(JsonSerializer.Serialize(requestDto, JSON_SERIALIZER_OPTIONS), Encoding.UTF8, "application/json");
|
request.Content = new StringContent(JsonSerializer.Serialize(requestDto, JSON_SERIALIZER_OPTIONS), Encoding.UTF8, "application/json");
|
||||||
|
return request;
|
||||||
using var response = await this.HttpClient.SendAsync(request, token);
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var responseBody = await response.Content.ReadAsStringAsync(token);
|
|
||||||
LOGGER.LogError("Tool calling messages request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody);
|
|
||||||
await ToolCallingMessages.SendToolCallingRequestFailedAsync((int)response.StatusCode);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.Content.ReadFromJsonAsync<AnthropicResponse>(JSON_SERIALIZER_OPTIONS, token);
|
return this.ReadServerSentEventsAsync("Anthropic", "messages call", RequestBuilder, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||||
|
|||||||
@ -3,12 +3,10 @@ using System.Net.Http.Headers;
|
|||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
using AIStudio.Chat;
|
using AIStudio.Chat;
|
||||||
using AIStudio.Models;
|
using AIStudio.Models;
|
||||||
using AIStudio.Models.Live;
|
using AIStudio.Models.Live;
|
||||||
using AIStudio.Provider.Anthropic;
|
|
||||||
using AIStudio.Provider.OpenAI;
|
using AIStudio.Provider.OpenAI;
|
||||||
using AIStudio.Provider.SelfHosted;
|
using AIStudio.Provider.SelfHosted;
|
||||||
using AIStudio.Settings;
|
using AIStudio.Settings;
|
||||||
@ -39,20 +37,7 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly ILogger logger;
|
private readonly ILogger logger;
|
||||||
|
|
||||||
protected static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new()
|
protected static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = ProviderJsonOptions.OPTIONS;
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
|
||||||
Converters =
|
|
||||||
{
|
|
||||||
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower),
|
|
||||||
new AnnotationConverter(),
|
|
||||||
new MessageBaseConverter(),
|
|
||||||
new SubContentConverter(),
|
|
||||||
new SubContentImageSourceConverter(),
|
|
||||||
new SubContentImageUrlConverter(),
|
|
||||||
},
|
|
||||||
AllowTrailingCommas = false
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Constructor for the base provider.
|
/// Constructor for the base provider.
|
||||||
@ -840,19 +825,20 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Streams the chat completion from the provider using the Chat Completion API.
|
/// Reads a server-sent event stream from the provider, line by line.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="providerName">The name of the provider.</param>
|
/// <remarks>
|
||||||
|
/// Everything on the way to a line is here: the retries, the timeouts, the cancellation, and
|
||||||
|
/// the messages the user gets to see when any of it fails. What a line means is not here --
|
||||||
|
/// that differs per wire format, and reading it is the caller's business.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="providerName">The name of the provider, for logging and error reporting.</param>
|
||||||
|
/// <param name="operationName">What is being streamed, for logging: a chat completion, say, or a responses call.</param>
|
||||||
/// <param name="requestBuilder">A function that builds the request.</param>
|
/// <param name="requestBuilder">A function that builds the request.</param>
|
||||||
/// <param name="token">The cancellation token to use.</param>
|
/// <param name="token">The cancellation token to use.</param>
|
||||||
/// <typeparam name="TDelta">The type of the delta lines inside the stream.</typeparam>
|
/// <returns>The events of the stream, in the order they arrived.</returns>
|
||||||
/// <typeparam name="TAnnotation">The type of the annotation lines inside the stream.</typeparam>
|
protected async IAsyncEnumerable<ServerSentEvent> ReadServerSentEventsAsync(string providerName, string operationName, Func<Task<HttpRequestMessage>> requestBuilder, [EnumeratorCancellation] CancellationToken token = default)
|
||||||
/// <returns>The stream of content chunks.</returns>
|
|
||||||
protected async IAsyncEnumerable<ContentStreamChunk> StreamChatCompletionInternal<TDelta, TAnnotation>(string providerName, Func<Task<HttpRequestMessage>> requestBuilder, [EnumeratorCancellation] CancellationToken token = default) where TDelta : IResponseStreamLine where TAnnotation : IAnnotationStreamLine
|
|
||||||
{
|
{
|
||||||
// Check if annotations are supported:
|
|
||||||
var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine);
|
|
||||||
|
|
||||||
StreamReader? streamReader = null;
|
StreamReader? streamReader = null;
|
||||||
using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token);
|
using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token);
|
||||||
var timeoutToken = timeoutTokenSource.Token;
|
var timeoutToken = timeoutTokenSource.Token;
|
||||||
@ -862,7 +848,7 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
var responseData = await this.SendRequest(requestBuilder, token, timeoutToken);
|
var responseData = await this.SendRequest(requestBuilder, token, timeoutToken);
|
||||||
if(responseData.IsFailedAfterAllRetries)
|
if(responseData.IsFailedAfterAllRetries)
|
||||||
{
|
{
|
||||||
this.logger.LogError($"The {providerName} chat completion failed: {responseData.ErrorMessage}");
|
this.logger.LogError("The {ProviderName} {OperationName} failed: {ErrorMessage}", providerName, operationName, responseData.ErrorMessage);
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -880,23 +866,25 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
{
|
{
|
||||||
if (token.IsCancellationRequested)
|
if (token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
this.logger.LogWarning("The user canceled the chat completion request for {ProviderName} '{ProviderInstanceName}' before the response stream was opened.", providerName, this.InstanceName);
|
this.logger.LogWarning("The user canceled the {OperationName} request for {ProviderName} '{ProviderInstanceName}' before the response stream was opened.", operationName, providerName, this.InstanceName);
|
||||||
}
|
}
|
||||||
else if (this.IsTimeoutException(e, token))
|
else if (this.IsTimeoutException(e, token))
|
||||||
{
|
{
|
||||||
await this.SendTimeoutError("opening the chat response stream");
|
await this.SendTimeoutError("opening the chat response stream");
|
||||||
this.logger.LogError(e, "Timed out while opening the chat completion stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName);
|
this.logger.LogError(e, "Timed out while opening the {OperationName} stream from {ProviderName} '{ProviderInstanceName}'.", operationName, providerName, this.InstanceName);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to communicate with the LLM provider '{0}'. There were some problems with the request. The provider message is: '{1}'"), this.InstanceName, e.Message)));
|
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to communicate with the LLM provider '{0}'. There were some problems with the request. The provider message is: '{1}'"), this.InstanceName, e.Message)));
|
||||||
this.logger.LogError($"Failed to stream chat completion from {providerName} '{this.InstanceName}': {e.Message}");
|
this.logger.LogError(e, "Failed to stream the {OperationName} from {ProviderName} '{ProviderInstanceName}': {ErrorMessage}", operationName, providerName, this.InstanceName, e.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamReader is null)
|
if (streamReader is null)
|
||||||
yield break;
|
yield break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
//
|
//
|
||||||
// Read the stream, line by line:
|
// Read the stream, line by line:
|
||||||
//
|
//
|
||||||
@ -910,15 +898,14 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'"), this.InstanceName, e.Message)));
|
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'"), this.InstanceName, e.Message)));
|
||||||
this.logger.LogWarning($"Failed to read the end-of-stream state from {providerName} '{this.InstanceName}': {e.Message}");
|
this.logger.LogWarning(e, "Failed to read the end-of-stream state from {ProviderName} '{ProviderInstanceName}': {ErrorMessage}", providerName, this.InstanceName, e.Message);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the token is canceled:
|
// Check if the token is canceled:
|
||||||
if (token.IsCancellationRequested)
|
if (token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
this.logger.LogWarning($"The user canceled the chat completion for {providerName} '{this.InstanceName}'.");
|
this.logger.LogWarning("The user canceled the {OperationName} for {ProviderName} '{ProviderInstanceName}'.", operationName, providerName, this.InstanceName);
|
||||||
streamReader.Close();
|
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -934,17 +921,17 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
{
|
{
|
||||||
if (token.IsCancellationRequested)
|
if (token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
this.logger.LogWarning("The user canceled the chat completion stream for {ProviderName} '{ProviderInstanceName}' while reading the next chunk.", providerName, this.InstanceName);
|
this.logger.LogWarning("The user canceled the {OperationName} stream for {ProviderName} '{ProviderInstanceName}' while reading the next chunk.", operationName, providerName, this.InstanceName);
|
||||||
}
|
}
|
||||||
else if (this.IsTimeoutException(e, token))
|
else if (this.IsTimeoutException(e, token))
|
||||||
{
|
{
|
||||||
await this.SendTimeoutError("reading the chat response stream");
|
await this.SendTimeoutError("reading the chat response stream");
|
||||||
this.logger.LogError(e, "Timed out while reading the chat stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName);
|
this.logger.LogError(e, "Timed out while reading the {OperationName} stream from {ProviderName} '{ProviderInstanceName}'.", operationName, providerName, this.InstanceName);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. Was not able to read the stream. The message is: '{1}'"), this.InstanceName, e.Message)));
|
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. Was not able to read the stream. The message is: '{1}'"), this.InstanceName, e.Message)));
|
||||||
this.logger.LogError($"Failed to read the stream from {providerName} '{this.InstanceName}': {e.Message}");
|
this.logger.LogError(e, "Failed to read the stream from {ProviderName} '{ProviderInstanceName}': {ErrorMessage}", providerName, this.InstanceName, e.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
@ -960,19 +947,49 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
if (this.TryCreateProviderRequestExceptionFromStreamLine(providerName, line, out var providerRequestException))
|
if (this.TryCreateProviderRequestExceptionFromStreamLine(providerName, line, out var providerRequestException))
|
||||||
throw providerRequestException;
|
throw providerRequestException;
|
||||||
|
|
||||||
// Skip lines that do not start with "data:". According
|
//
|
||||||
// to the specification, we only want to read the data lines:
|
// Only data lines carry a payload. Every other line goes out as it is, because
|
||||||
if (!TryGetServerSentEventData(line, out var jsonData))
|
// some of them still say something the caller has to act on.
|
||||||
|
//
|
||||||
|
TryGetServerSentEventData(line, out var data);
|
||||||
|
yield return new ServerSentEvent(line, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
streamReader.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams the chat completion from the provider using the Chat Completion API.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="providerName">The name of the provider.</param>
|
||||||
|
/// <param name="requestBuilder">A function that builds the request.</param>
|
||||||
|
/// <param name="token">The cancellation token to use.</param>
|
||||||
|
/// <typeparam name="TDelta">The type of the delta lines inside the stream.</typeparam>
|
||||||
|
/// <typeparam name="TAnnotation">The type of the annotation lines inside the stream.</typeparam>
|
||||||
|
/// <returns>The stream of content chunks.</returns>
|
||||||
|
protected async IAsyncEnumerable<ContentStreamChunk> StreamChatCompletionInternal<TDelta, TAnnotation>(string providerName, Func<Task<HttpRequestMessage>> requestBuilder, [EnumeratorCancellation] CancellationToken token = default) where TDelta : IResponseStreamLine where TAnnotation : IAnnotationStreamLine
|
||||||
|
{
|
||||||
|
// Check if annotations are supported:
|
||||||
|
var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine);
|
||||||
|
|
||||||
|
await foreach (var serverSentEvent in this.ReadServerSentEventsAsync(providerName, "chat completion", requestBuilder, token))
|
||||||
|
{
|
||||||
|
// Skip lines without a payload. According to the specification,
|
||||||
|
// we only want to read the data lines:
|
||||||
|
if (serverSentEvent.Data.Length is 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Check if the line is the end of the stream:
|
// Check if the line is the end of the stream:
|
||||||
if (jsonData is "[DONE]")
|
if (serverSentEvent.Data is "[DONE]")
|
||||||
yield break;
|
yield break;
|
||||||
|
|
||||||
//
|
//
|
||||||
// Process annotation lines:
|
// Process annotation lines:
|
||||||
//
|
//
|
||||||
if (annotationSupported && line.Contains("""
|
if (annotationSupported && serverSentEvent.Line.Contains("""
|
||||||
"annotations":[
|
"annotations":[
|
||||||
""", StringComparison.InvariantCulture))
|
""", StringComparison.InvariantCulture))
|
||||||
{
|
{
|
||||||
@ -981,7 +998,7 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Deserialize the JSON data:
|
// Deserialize the JSON data:
|
||||||
providerResponse = JsonSerializer.Deserialize<TAnnotation>(jsonData, JSON_SERIALIZER_OPTIONS);
|
providerResponse = JsonSerializer.Deserialize<TAnnotation>(serverSentEvent.Data, JSON_SERIALIZER_OPTIONS);
|
||||||
|
|
||||||
if (providerResponse is null)
|
if (providerResponse is null)
|
||||||
continue;
|
continue;
|
||||||
@ -1009,7 +1026,7 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Deserialize the JSON data:
|
// Deserialize the JSON data:
|
||||||
providerResponse = JsonSerializer.Deserialize<TDelta>(jsonData, JSON_SERIALIZER_OPTIONS);
|
providerResponse = JsonSerializer.Deserialize<TDelta>(serverSentEvent.Data, JSON_SERIALIZER_OPTIONS);
|
||||||
|
|
||||||
if (providerResponse is null)
|
if (providerResponse is null)
|
||||||
continue;
|
continue;
|
||||||
@ -1028,8 +1045,6 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
yield return providerResponse.GetContent();
|
yield return providerResponse.GetContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
streamReader.Dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -1046,124 +1061,21 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
// Check if annotations are supported:
|
// Check if annotations are supported:
|
||||||
var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine);
|
var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine);
|
||||||
|
|
||||||
StreamReader? streamReader = null;
|
await foreach (var serverSentEvent in this.ReadServerSentEventsAsync(providerName, "responses call", requestBuilder, token))
|
||||||
using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token);
|
|
||||||
var timeoutToken = timeoutTokenSource.Token;
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
// Send the request using exponential backoff:
|
// Check if the line is the end of the stream. This one is read off the raw line
|
||||||
var responseData = await this.SendRequest(requestBuilder, token, timeoutToken);
|
// rather than off a payload, because it has none:
|
||||||
if(responseData.IsFailedAfterAllRetries)
|
if (serverSentEvent.Line.StartsWith("event: response.completed", StringComparison.InvariantCulture))
|
||||||
{
|
|
||||||
this.logger.LogError($"The {providerName} responses call failed: {responseData.ErrorMessage}");
|
|
||||||
yield break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the response stream:
|
|
||||||
var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(timeoutToken);
|
|
||||||
|
|
||||||
// Add a stream reader to read the stream, line by line:
|
|
||||||
streamReader = new StreamReader(providerStream);
|
|
||||||
}
|
|
||||||
catch(ProviderRequestException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch(Exception e)
|
|
||||||
{
|
|
||||||
if (token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
this.logger.LogWarning("The user canceled the responses request for {ProviderName} '{ProviderInstanceName}' before the response stream was opened.", providerName, this.InstanceName);
|
|
||||||
}
|
|
||||||
else if (this.IsTimeoutException(e, token))
|
|
||||||
{
|
|
||||||
await this.SendTimeoutError("opening the chat response stream");
|
|
||||||
this.logger.LogError(e, "Timed out while opening the responses stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to communicate with the LLM provider '{0}'. There were some problems with the request. The provider message is: '{1}'"), this.InstanceName, e.Message)));
|
|
||||||
this.logger.LogError($"Failed to stream responses from {providerName} '{this.InstanceName}': {e.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (streamReader is null)
|
|
||||||
yield break;
|
yield break;
|
||||||
|
|
||||||
//
|
// Skip lines without a payload:
|
||||||
// Read the stream, line by line:
|
if (serverSentEvent.Data.Length is 0)
|
||||||
//
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if(streamReader.EndOfStream)
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'"), this.InstanceName, e.Message)));
|
|
||||||
this.logger.LogWarning($"Failed to read the end-of-stream state from {providerName} '{this.InstanceName}': {e.Message}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the token is canceled:
|
|
||||||
if (token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
this.logger.LogWarning($"The user canceled the responses for {providerName} '{this.InstanceName}'.");
|
|
||||||
streamReader.Close();
|
|
||||||
yield break;
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Read the next line:
|
|
||||||
//
|
|
||||||
string? line;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
line = await streamReader.ReadLineAsync(timeoutToken);
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
if (token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
this.logger.LogWarning("The user canceled the responses stream for {ProviderName} '{ProviderInstanceName}' while reading the next chunk.", providerName, this.InstanceName);
|
|
||||||
}
|
|
||||||
else if (this.IsTimeoutException(e, token))
|
|
||||||
{
|
|
||||||
await this.SendTimeoutError("reading the chat response stream");
|
|
||||||
this.logger.LogError(e, "Timed out while reading the responses stream from {ProviderName} '{ProviderInstanceName}'.", providerName, this.InstanceName);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. Was not able to read the stream. The message is: '{1}'"), this.InstanceName, e.Message)));
|
|
||||||
this.logger.LogError($"Failed to read the stream from {providerName} '{this.InstanceName}': {e.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line is null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Skip empty lines:
|
|
||||||
if (string.IsNullOrWhiteSpace(line))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (this.TryCreateProviderRequestExceptionFromStreamLine(providerName, line, out var providerRequestException))
|
|
||||||
throw providerRequestException;
|
|
||||||
|
|
||||||
// Check if the line is the end of the stream:
|
|
||||||
if (line.StartsWith("event: response.completed", StringComparison.InvariantCulture))
|
|
||||||
yield break;
|
|
||||||
|
|
||||||
if (!TryGetServerSentEventData(line, out var jsonData))
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
//
|
//
|
||||||
// Find delta lines:
|
// Find delta lines:
|
||||||
//
|
//
|
||||||
if (jsonData.StartsWith("""
|
if (serverSentEvent.Data.StartsWith("""
|
||||||
{"type":"response.output_text.delta"
|
{"type":"response.output_text.delta"
|
||||||
""", StringComparison.InvariantCulture))
|
""", StringComparison.InvariantCulture))
|
||||||
{
|
{
|
||||||
@ -1171,7 +1083,7 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Deserialize the JSON data:
|
// Deserialize the JSON data:
|
||||||
providerResponse = JsonSerializer.Deserialize<TDelta>(jsonData, JSON_SERIALIZER_OPTIONS);
|
providerResponse = JsonSerializer.Deserialize<TDelta>(serverSentEvent.Data, JSON_SERIALIZER_OPTIONS);
|
||||||
|
|
||||||
if (providerResponse is null)
|
if (providerResponse is null)
|
||||||
continue;
|
continue;
|
||||||
@ -1193,7 +1105,7 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
//
|
//
|
||||||
// Find annotation added lines:
|
// Find annotation added lines:
|
||||||
//
|
//
|
||||||
else if (annotationSupported && jsonData.StartsWith(
|
else if (annotationSupported && serverSentEvent.Data.StartsWith(
|
||||||
"""
|
"""
|
||||||
{"type":"response.output_text.annotation.added"
|
{"type":"response.output_text.annotation.added"
|
||||||
""", StringComparison.InvariantCulture))
|
""", StringComparison.InvariantCulture))
|
||||||
@ -1202,7 +1114,7 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Deserialize the JSON data:
|
// Deserialize the JSON data:
|
||||||
providerResponse = JsonSerializer.Deserialize<TAnnotation>(jsonData, JSON_SERIALIZER_OPTIONS);
|
providerResponse = JsonSerializer.Deserialize<TAnnotation>(serverSentEvent.Data, JSON_SERIALIZER_OPTIONS);
|
||||||
|
|
||||||
if (providerResponse is null)
|
if (providerResponse is null)
|
||||||
continue;
|
continue;
|
||||||
@ -1221,8 +1133,6 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
yield return new(string.Empty, providerResponse.GetSources());
|
yield return new(string.Empty, providerResponse.GetSources());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
streamReader.Dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -1293,8 +1203,9 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
{
|
{
|
||||||
var adapter = new ChatCompletionToolCallingAdapter<TRequest>(requestFactory, systemPrompt, apiParameters,
|
var adapter = new ChatCompletionToolCallingAdapter<TRequest>(requestFactory, systemPrompt, apiParameters,
|
||||||
runnableTools.Select(x => ProviderToolAdapters.ToChatCompletionTool(x.Definition)).ToList(), runnableTools,
|
runnableTools.Select(x => ProviderToolAdapters.ToChatCompletionTool(x.Definition)).ToList(), runnableTools,
|
||||||
(requestDto, requestToken) => this.ExecuteChatCompletionRequest(requestDto, requestPath, requestedSecret, headersAction, requestToken),
|
(requestDto, requestToken) => this.StreamChatCompletionRequest(requestDto, providerName, requestPath, requestedSecret, headersAction, requestToken),
|
||||||
this.InstanceName, this.logger);
|
ChatCompletionSourceReader.Read<TDelta, TAnnotation>,
|
||||||
|
this.logger);
|
||||||
|
|
||||||
var loop = Program.SERVICE_PROVIDER.GetRequiredService<IToolCallingLoop>();
|
var loop = Program.SERVICE_PROVIDER.GetRequiredService<IToolCallingLoop>();
|
||||||
var loopContext = new ToolCallingLoopContext
|
var loopContext = new ToolCallingLoopContext
|
||||||
@ -1365,16 +1276,17 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
CapabilityOverrides = this.CapabilityOverrides,
|
CapabilityOverrides = this.CapabilityOverrides,
|
||||||
};
|
};
|
||||||
|
|
||||||
private async Task<ChatCompletionResponse?> ExecuteChatCompletionRequest(ChatCompletionAPIRequest requestDto, string requestPath, RequestedSecret requestedSecret,
|
/// <summary>
|
||||||
Action<HttpRequestHeaders>? headersAction, CancellationToken token)
|
/// Runs one round of a tool calling conversation against a Chat Completions endpoint.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Nothing but the HTTP request is done here. Reading the events is the adapter's business,
|
||||||
|
/// and everything on the way to them -- the retries, the timeouts, the error classification --
|
||||||
|
/// belongs to the shared stream reader, which the tool rounds used to go without.
|
||||||
|
/// </remarks>
|
||||||
|
private IAsyncEnumerable<ServerSentEvent> StreamChatCompletionRequest(ChatCompletionAPIRequest requestDto, string providerName, string requestPath,
|
||||||
|
RequestedSecret requestedSecret, Action<HttpRequestHeaders>? headersAction, CancellationToken token)
|
||||||
{
|
{
|
||||||
var responseData = await this.SendRequest(RequestBuilder, token);
|
|
||||||
if (responseData.IsFailedAfterAllRetries)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
using var response = responseData.Response!;
|
|
||||||
return await response.Content.ReadFromJsonAsync<ChatCompletionResponse>(JSON_SERIALIZER_OPTIONS, token);
|
|
||||||
|
|
||||||
async Task<HttpRequestMessage> RequestBuilder()
|
async Task<HttpRequestMessage> RequestBuilder()
|
||||||
{
|
{
|
||||||
var request = new HttpRequestMessage(HttpMethod.Post, requestPath);
|
var request = new HttpRequestMessage(HttpMethod.Post, requestPath);
|
||||||
@ -1385,6 +1297,8 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
request.Content = new StringContent(JsonSerializer.Serialize(requestDto, JSON_SERIALIZER_OPTIONS), Encoding.UTF8, "application/json");
|
request.Content = new StringContent(JsonSerializer.Serialize(requestDto, JSON_SERIALIZER_OPTIONS), Encoding.UTF8, "application/json");
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.ReadServerSentEventsAsync(providerName, "chat completion", RequestBuilder, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
namespace AIStudio.Provider.OpenAI;
|
|
||||||
|
|
||||||
public sealed record ChatCompletionResponse
|
|
||||||
{
|
|
||||||
public string Id { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public string Model { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public IList<ChatCompletionResponseChoice> Choices { get; init; } = [];
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
namespace AIStudio.Provider.OpenAI;
|
|
||||||
|
|
||||||
public sealed record ChatCompletionResponseChoice
|
|
||||||
{
|
|
||||||
public int Index { get; init; }
|
|
||||||
|
|
||||||
public string FinishReason { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public ChatCompletionResponseMessage Message { get; init; } = new();
|
|
||||||
}
|
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace AIStudio.Provider.OpenAI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the sources a provider puts into its Chat Completions stream.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Where those sit differs per provider: OpenAI announces them on annotation lines of their own,
|
||||||
|
/// Perplexity puts its search results into the very line that carries the text. The plain text
|
||||||
|
/// path reads both through the provider's own stream line types, and so does this -- otherwise
|
||||||
|
/// the tool calling rounds would be the one place where a citation link goes missing.
|
||||||
|
/// </remarks>
|
||||||
|
public static class ChatCompletionSourceReader
|
||||||
|
{
|
||||||
|
private const string DONE = "[DONE]";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads whatever sources one line of the stream announced.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serverSentEvent">The event to read.</param>
|
||||||
|
/// <typeparam name="TDelta">The provider's delta stream line type.</typeparam>
|
||||||
|
/// <typeparam name="TAnnotation">The provider's annotation stream line type.</typeparam>
|
||||||
|
/// <returns>The sources of this line, empty when it announced none.</returns>
|
||||||
|
public static IList<ISource> Read<TDelta, TAnnotation>(ServerSentEvent serverSentEvent)
|
||||||
|
where TDelta : IResponseStreamLine
|
||||||
|
where TAnnotation : IAnnotationStreamLine
|
||||||
|
{
|
||||||
|
if (serverSentEvent.Data.Length is 0 || serverSentEvent.Data is DONE)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
//
|
||||||
|
// The same split the plain text path makes, and for the same reason: a line is either an
|
||||||
|
// annotation line or a delta line, and reading it as both would count its sources twice.
|
||||||
|
//
|
||||||
|
var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine);
|
||||||
|
if (annotationSupported && serverSentEvent.Line.Contains("""
|
||||||
|
"annotations":[
|
||||||
|
""", StringComparison.InvariantCulture))
|
||||||
|
{
|
||||||
|
var annotationLine = TryDeserialize<TAnnotation>(serverSentEvent.Data);
|
||||||
|
return annotationLine is not null && annotationLine.ContainsSources() ? annotationLine.GetSources() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var deltaLine = TryDeserialize<TDelta>(serverSentEvent.Data);
|
||||||
|
return deltaLine is not null && deltaLine.ContainsSources() ? deltaLine.GetSources() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T? TryDeserialize<T>(string json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<T>(json, ProviderJsonOptions.OPTIONS);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace AIStudio.Provider.OpenAI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What one choice of a streamed Chat Completions answer adds in this line.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is the delta of the plain text path plus the two fields that path has no use for: the
|
||||||
|
/// reasoning some providers send alongside, and the tool calls the model asks for.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record ChatCompletionStreamDelta
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The content as it arrived: a string for most providers, a list of parts for some.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("content")]
|
||||||
|
public JsonElement? RawContent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The text of this fragment, whichever shape it arrived in.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public string Content => ChatCompletionContent.GetText(this.RawContent) ?? string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The reasoning text some providers stream next to the answer.
|
||||||
|
/// </summary>
|
||||||
|
public string? ReasoningContent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The fragments of the tool calls the model is asking for.
|
||||||
|
/// </summary>
|
||||||
|
public IList<ChatCompletionToolCallDelta?>? ToolCalls { get; init; }
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
namespace AIStudio.Provider.OpenAI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What one line of a streamed Chat Completions answer has to show to the user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="TextDelta">The text this line carried, empty when it carried none.</param>
|
||||||
|
/// <param name="Sources">The sources this line announced, empty when it announced none.</param>
|
||||||
|
public readonly record struct ChatCompletionStreamPart(string TextDelta, IList<ISource> Sources)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The part of a line which says nothing to the user, such as a fragment of a tool call.
|
||||||
|
/// </summary>
|
||||||
|
public static ChatCompletionStreamPart Nothing => new(string.Empty, []);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this part has anything to show at all.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasContent => this.TextDelta.Length > 0 || this.Sources.Count > 0;
|
||||||
|
}
|
||||||
@ -0,0 +1,227 @@
|
|||||||
|
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(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
namespace AIStudio.Provider.OpenAI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One fragment of a tool call in a streamed Chat Completions answer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A tool call arrives in pieces: the ID and the name usually with the first fragment, the
|
||||||
|
/// arguments spread over as many as the model needs. The index is what ties the pieces of one
|
||||||
|
/// call together while another call is being written at the same time.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Index">Which call this fragment belongs to; null when the provider omits it.</param>
|
||||||
|
/// <param name="Id">The ID of the call, sent once by most providers and repeated by some.</param>
|
||||||
|
/// <param name="Type">The kind of call, which is "function" for everything we offer.</param>
|
||||||
|
/// <param name="Function">The name and the arguments fragment of the call.</param>
|
||||||
|
public sealed record ChatCompletionToolCallDelta(int? Index, string? Id, string? Type, ChatCompletionToolFunction? Function);
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
using AIStudio.Tools.ToolCallingSystem;
|
using AIStudio.Tools.ToolCallingSystem;
|
||||||
@ -17,8 +18,9 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
|
|||||||
TextMessage systemPrompt, IDictionary<string, object> apiParameters,
|
TextMessage systemPrompt, IDictionary<string, object> apiParameters,
|
||||||
IList<object> providerTools,
|
IList<object> providerTools,
|
||||||
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
|
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
|
||||||
Func<ChatCompletionAPIRequest, CancellationToken, Task<ChatCompletionResponse?>> executeRequestAsync,
|
Func<ChatCompletionAPIRequest, CancellationToken, IAsyncEnumerable<ServerSentEvent>> streamRequestAsync,
|
||||||
string providerInstanceName, ILogger logger)
|
Func<ServerSentEvent, IList<ISource>> readSources,
|
||||||
|
ILogger logger)
|
||||||
: IToolCallingProviderAdapter where TRequest : ChatCompletionAPIRequest
|
: IToolCallingProviderAdapter where TRequest : ChatCompletionAPIRequest
|
||||||
{
|
{
|
||||||
private readonly List<IMessageBase> internalMessages = [];
|
private readonly List<IMessageBase> internalMessages = [];
|
||||||
@ -30,7 +32,7 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
|
|||||||
public IReadOnlyList<string> RecordedRequestTexts => this.recordedRequestTexts;
|
public IReadOnlyList<string> RecordedRequestTexts => this.recordedRequestTexts;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ToolCallingRound?> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default)
|
public async IAsyncEnumerable<ToolCallingStreamEvent> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, [EnumeratorCancellation] CancellationToken token = default)
|
||||||
{
|
{
|
||||||
var requestSystemPrompt = finalResponseInstruction is null
|
var requestSystemPrompt = finalResponseInstruction is null
|
||||||
? systemPrompt : systemPrompt with
|
? systemPrompt : systemPrompt with
|
||||||
@ -42,7 +44,7 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
|
|||||||
var requestDto = requestDtoBase with
|
var requestDto = requestDtoBase with
|
||||||
{
|
{
|
||||||
Messages = [..requestDtoBase.Messages, ..this.internalMessages],
|
Messages = [..requestDtoBase.Messages, ..this.internalMessages],
|
||||||
Stream = false,
|
Stream = true,
|
||||||
|
|
||||||
//
|
//
|
||||||
// AI Studio runs tool calls one after another, so asking for parallel calls would
|
// AI Studio runs tool calls one after another, so asking for parallel calls would
|
||||||
@ -52,34 +54,32 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
|
|||||||
ParallelToolCalls = requestDtoBase.Tools is null ? null : false,
|
ParallelToolCalls = requestDtoBase.Tools is null ? null : false,
|
||||||
};
|
};
|
||||||
|
|
||||||
var response = await executeRequestAsync(requestDto, token);
|
//
|
||||||
if (response is null)
|
// The text goes out while it is being written; the tool calls are put back together
|
||||||
return null;
|
// behind it, fragment by fragment.
|
||||||
|
//
|
||||||
// The response comes from a provider, so its shape is a promise rather than a guarantee:
|
var accumulator = new ChatCompletionToolCallAccumulator(readSources);
|
||||||
// a JSON null for the choices field overwrites the initialized property with null.
|
await foreach (var serverSentEvent in streamRequestAsync(requestDto, token))
|
||||||
// ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
|
|
||||||
var responseChoice = response.Choices?.FirstOrDefault();
|
|
||||||
if (responseChoice?.Message is null)
|
|
||||||
{
|
{
|
||||||
logger.LogError(
|
var part = accumulator.Process(serverSentEvent);
|
||||||
"The tool calling response did not contain a usable choice. ProviderInstanceName={ProviderInstanceName}, ChoiceCount={ChoiceCount}",
|
if (part.HasContent)
|
||||||
providerInstanceName,
|
yield return ToolCallingStreamEvent.TextDelta(new ContentStreamChunk(part.TextDelta, part.Sources));
|
||||||
response.Choices?.Count ?? 0);
|
|
||||||
|
|
||||||
throw ToolCallingMessages.InvalidToolCallingResponse(providerInstanceName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastResponseMessage = responseChoice.Message;
|
var message = accumulator.Build();
|
||||||
var preparedCalls = this.PrepareToolCalls(responseChoice.Message.ToolCalls ?? []);
|
if (message is null)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
this.lastResponseMessage = message;
|
||||||
|
var preparedCalls = this.PrepareToolCalls(message.ToolCalls ?? []);
|
||||||
this.lastToolCalls = preparedCalls.Select(x => x.ToolCall).ToList();
|
this.lastToolCalls = preparedCalls.Select(x => x.ToolCall).ToList();
|
||||||
|
|
||||||
return new ToolCallingRound(
|
yield return ToolCallingStreamEvent.RoundCompleted(new ToolCallingRound(
|
||||||
responseChoice.Message.Content ?? string.Empty,
|
message.Content ?? string.Empty,
|
||||||
preparedCalls
|
preparedCalls
|
||||||
.Select(x => new ToolCallingRequestedCall(x.ToolCall.Id!, x.ToolCall.Function!.Name!, x.ToolCall.Function!.Arguments!, x.IsValid))
|
.Select(x => new ToolCallingRequestedCall(x.ToolCall.Id!, x.ToolCall.Function!.Name!, x.ToolCall.Function!.Arguments!, x.IsValid))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
[]);
|
[]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
namespace AIStudio.Provider.OpenAI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One choice of a streamed Chat Completions answer, as the tool calling rounds read it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Index">The index of the choice; we only ever work with the first one.</param>
|
||||||
|
/// <param name="Delta">What this line adds to the choice.</param>
|
||||||
|
/// <param name="FinishReason">Why the model stopped, set on the last line of the choice.</param>
|
||||||
|
public sealed record ChatCompletionToolStreamChoice(int Index, ChatCompletionStreamDelta? Delta, string? FinishReason);
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
namespace AIStudio.Provider.OpenAI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One line of a streamed Chat Completions answer, as the tool calling rounds read it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The plain text path reads the very same lines through its own provider-specific type, which
|
||||||
|
/// knows about text and about the sources some providers put in it. Reading a line twice costs
|
||||||
|
/// nothing next to the request it arrived on, and it keeps the tool calls out of a type every
|
||||||
|
/// provider implements -- including those which never call a tool.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Id">The ID of the answer.</param>
|
||||||
|
/// <param name="Choices">The choices this line adds to.</param>
|
||||||
|
public sealed record ChatCompletionToolStreamLine(string? Id, IList<ChatCompletionToolStreamChoice?>? Choices);
|
||||||
@ -229,7 +229,7 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
|
|||||||
additionalApiParameters,
|
additionalApiParameters,
|
||||||
providerTools,
|
providerTools,
|
||||||
runnableTools,
|
runnableTools,
|
||||||
(requestDto, requestToken) => this.ExecuteResponsesRequest(requestDto, requestedSecret, requestToken));
|
(requestDto, requestToken) => this.StreamResponsesRequest(requestDto, requestedSecret, requestToken));
|
||||||
|
|
||||||
var loop = Program.SERVICE_PROVIDER.GetRequiredService<IToolCallingLoop>();
|
var loop = Program.SERVICE_PROVIDER.GetRequiredService<IToolCallingLoop>();
|
||||||
var loopContext = new ToolCallingLoopContext
|
var loopContext = new ToolCallingLoopContext
|
||||||
@ -316,22 +316,25 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
|
|||||||
yield return content;
|
yield return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ResponsesResponse?> ExecuteResponsesRequest(ResponsesAPIRequest requestDto, RequestedSecret requestedSecret, CancellationToken token)
|
/// <summary>
|
||||||
|
/// Runs one round of a tool calling conversation against the Responses API.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Nothing but the HTTP request is done here. The retries, the timeouts, and the error
|
||||||
|
/// classification come from the shared stream reader, which the tool calling rounds used to
|
||||||
|
/// go without; reading the events is the adapter's business.
|
||||||
|
/// </remarks>
|
||||||
|
private IAsyncEnumerable<ServerSentEvent> StreamResponsesRequest(ResponsesAPIRequest requestDto, RequestedSecret requestedSecret, CancellationToken token)
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, "responses");
|
return this.ReadServerSentEventsAsync("OpenAI", "responses call", RequestBuilder, token);
|
||||||
|
|
||||||
|
async Task<HttpRequestMessage> RequestBuilder()
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "responses");
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION));
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION));
|
||||||
request.Content = new StringContent(JsonSerializer.Serialize(requestDto, JSON_SERIALIZER_OPTIONS), Encoding.UTF8, "application/json");
|
request.Content = new StringContent(JsonSerializer.Serialize(requestDto, JSON_SERIALIZER_OPTIONS), Encoding.UTF8, "application/json");
|
||||||
|
return request;
|
||||||
using var response = await this.HttpClient.SendAsync(request, token);
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var responseBody = await response.Content.ReadAsStringAsync(token);
|
|
||||||
LOGGER.LogError("Tool calling Responses API request failed with status code {ResponseStatusCode} and body: '{ResponseBody}'.", response.StatusCode, responseBody);
|
|
||||||
await ToolCallingMessages.SendToolCallingRequestFailedAsync((int)response.StatusCode);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.Content.ReadFromJsonAsync<ResponsesResponse>(JSON_SERIALIZER_OPTIONS, token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||||
|
|||||||
@ -0,0 +1,13 @@
|
|||||||
|
namespace AIStudio.Provider.OpenAI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The closing line of a streamed Responses API call, which repeats the whole response.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Everything the round produced comes back here, reasoning items included, in the same shape a
|
||||||
|
/// non-streamed call would have returned. That is why a streamed tool calling round needs no
|
||||||
|
/// reassembly: this line is the round.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Type">The type of the stream event.</param>
|
||||||
|
/// <param name="Response">The response as a non-streamed call would have returned it.</param>
|
||||||
|
public sealed record ResponsesCompletedStreamLine(string Type, ResponsesResponse? Response);
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace AIStudio.Provider.OpenAI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a streamed Responses API call back into the response the tool calling loop works with.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The API repeats the whole response when it is done, reasoning items included, so nothing has
|
||||||
|
/// to be reassembled from fragments: that closing event is the round. What this type does beyond
|
||||||
|
/// taking it is hand out text and sources while they arrive, and keep the finished output items
|
||||||
|
/// as a fallback for gateways which never send that closing event.<br/><br/>
|
||||||
|
/// No HTTP, no dependency injection, no provider: everything here is a decision about bytes, and
|
||||||
|
/// those are the decisions worth having a test for.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ResponsesStreamAccumulator
|
||||||
|
{
|
||||||
|
private const string EVENT_COMPLETED = "response.completed";
|
||||||
|
private const string EVENT_TEXT_DELTA = "response.output_text.delta";
|
||||||
|
private const string EVENT_ANNOTATION_ADDED = "response.output_text.annotation.added";
|
||||||
|
private const string EVENT_OUTPUT_ITEM_DONE = "response.output_item.done";
|
||||||
|
|
||||||
|
private readonly List<JsonElement> completedOutputItems = [];
|
||||||
|
private ResponsesResponse? completedResponse;
|
||||||
|
|
||||||
|
/// <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 and sources of this event, both empty when it carried neither.</returns>
|
||||||
|
public ResponsesStreamPart Process(ServerSentEvent serverSentEvent)
|
||||||
|
{
|
||||||
|
if (serverSentEvent.Data.Length is 0)
|
||||||
|
return ResponsesStreamPart.Nothing;
|
||||||
|
|
||||||
|
string eventType;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(serverSentEvent.Data);
|
||||||
|
var root = document.RootElement;
|
||||||
|
if (root.ValueKind is not JsonValueKind.Object ||
|
||||||
|
!root.TryGetProperty("type", out var typeProperty) ||
|
||||||
|
typeProperty.ValueKind is not JsonValueKind.String)
|
||||||
|
return ResponsesStreamPart.Nothing;
|
||||||
|
|
||||||
|
eventType = typeProperty.GetString() ?? string.Empty;
|
||||||
|
|
||||||
|
//
|
||||||
|
// The item is cloned because its document is disposed at the end of this block, and
|
||||||
|
// an element which outlives its document reads memory that is no longer there.
|
||||||
|
//
|
||||||
|
if (eventType is EVENT_OUTPUT_ITEM_DONE && root.TryGetProperty("item", out var outputItem))
|
||||||
|
this.completedOutputItems.Add(outputItem.Clone());
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// A line we cannot read is a line we skip, exactly as the plain text path does:
|
||||||
|
return ResponsesStreamPart.Nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (eventType)
|
||||||
|
{
|
||||||
|
case EVENT_COMPLETED:
|
||||||
|
this.completedResponse = TryDeserialize<ResponsesCompletedStreamLine>(serverSentEvent.Data)?.Response ?? this.completedResponse;
|
||||||
|
return ResponsesStreamPart.Nothing;
|
||||||
|
|
||||||
|
case EVENT_TEXT_DELTA:
|
||||||
|
var deltaLine = TryDeserialize<ResponsesDeltaStreamLine>(serverSentEvent.Data);
|
||||||
|
if (deltaLine is null || !deltaLine.ContainsContent())
|
||||||
|
return ResponsesStreamPart.Nothing;
|
||||||
|
|
||||||
|
return new ResponsesStreamPart(deltaLine.GetContent().Content, []);
|
||||||
|
|
||||||
|
case EVENT_ANNOTATION_ADDED:
|
||||||
|
var annotationLine = TryDeserialize<ResponsesAnnotationStreamLine>(serverSentEvent.Data);
|
||||||
|
if (annotationLine is null || !annotationLine.ContainsSources())
|
||||||
|
return ResponsesStreamPart.Nothing;
|
||||||
|
|
||||||
|
return new ResponsesStreamPart(string.Empty, annotationLine.GetSources());
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ResponsesStreamPart.Nothing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the response of the round from everything the stream said.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// The response, or null when the stream ended before it said anything usable. Null is how a
|
||||||
|
/// failed request and a truncated stream look from here, and both end the round.
|
||||||
|
/// </returns>
|
||||||
|
public ResponsesResponse? Build()
|
||||||
|
{
|
||||||
|
if (this.completedResponse is not null)
|
||||||
|
return this.completedResponse;
|
||||||
|
|
||||||
|
if (this.completedOutputItems.Count is 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
//
|
||||||
|
// No closing event came, so the round is put back together from the items which did.
|
||||||
|
// Reasoning items are among them, which is what the next request needs to continue.
|
||||||
|
//
|
||||||
|
return new ResponsesResponse
|
||||||
|
{
|
||||||
|
Output = [..this.completedOutputItems],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T? TryDeserialize<T>(string json) where T : class
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<T>(json, ProviderJsonOptions.OPTIONS);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
namespace AIStudio.Provider.OpenAI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What one line of a streamed Responses API call has to show to the user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="TextDelta">The text this line carried, empty when it carried none.</param>
|
||||||
|
/// <param name="Sources">The sources this line announced, empty when it announced none.</param>
|
||||||
|
public readonly record struct ResponsesStreamPart(string TextDelta, IList<ISource> Sources)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The part of a line which says nothing to the user, such as a bookkeeping event.
|
||||||
|
/// </summary>
|
||||||
|
public static ResponsesStreamPart Nothing => new(string.Empty, []);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this part has anything to show at all.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasContent => this.TextDelta.Length > 0 || this.Sources.Count > 0;
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
using AIStudio.Tools.ToolCallingSystem;
|
using AIStudio.Tools.ToolCallingSystem;
|
||||||
using AIStudio.Tools.ToolCallingSystem.Harness;
|
using AIStudio.Tools.ToolCallingSystem.Harness;
|
||||||
|
|
||||||
@ -13,7 +15,7 @@ namespace AIStudio.Provider.OpenAI;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> baseInput, IDictionary<string, object> apiParameters, IList<object> providerTools,
|
public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> baseInput, IDictionary<string, object> apiParameters, IList<object> providerTools,
|
||||||
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
|
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
|
||||||
Func<ResponsesAPIRequest, CancellationToken, Task<ResponsesResponse?>> executeRequestAsync) : IToolCallingProviderAdapter
|
Func<ResponsesAPIRequest, CancellationToken, IAsyncEnumerable<ServerSentEvent>> streamRequestAsync) : IToolCallingProviderAdapter
|
||||||
{
|
{
|
||||||
private readonly List<object> internalItems = [];
|
private readonly List<object> internalItems = [];
|
||||||
private readonly List<string> recordedRequestTexts = [];
|
private readonly List<string> recordedRequestTexts = [];
|
||||||
@ -32,7 +34,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
|
|||||||
private readonly IList<object> effectiveProviderTools = BuildEffectiveProviderTools(providerTools, runnableTools);
|
private readonly IList<object> effectiveProviderTools = BuildEffectiveProviderTools(providerTools, runnableTools);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ToolCallingRound?> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default)
|
public async IAsyncEnumerable<ToolCallingStreamEvent> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, [EnumeratorCancellation] CancellationToken token = default)
|
||||||
{
|
{
|
||||||
var requestInput = new List<object>(baseInput);
|
var requestInput = new List<object>(baseInput);
|
||||||
if (finalResponseInstruction is not null && requestInput.FirstOrDefault() is TextMessage systemPrompt)
|
if (finalResponseInstruction is not null && requestInput.FirstOrDefault() is TextMessage systemPrompt)
|
||||||
@ -45,21 +47,34 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
|
|||||||
|
|
||||||
requestInput.AddRange(this.internalItems);
|
requestInput.AddRange(this.internalItems);
|
||||||
|
|
||||||
var response = await executeRequestAsync(new ResponsesAPIRequest
|
var request = new ResponsesAPIRequest
|
||||||
{
|
{
|
||||||
Model = chatModel.Id,
|
Model = chatModel.Id,
|
||||||
Input = requestInput,
|
Input = requestInput,
|
||||||
Stream = false,
|
Stream = true,
|
||||||
Store = false,
|
Store = false,
|
||||||
Tools = includeTools ? this.effectiveProviderTools : [],
|
Tools = includeTools ? this.effectiveProviderTools : [],
|
||||||
AdditionalApiParameters = apiParameters,
|
AdditionalApiParameters = apiParameters,
|
||||||
}, token);
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// The text goes out while it is being written, the round only once the stream closed it.
|
||||||
|
// Sources travel with the text because the API announces them as it cites them.
|
||||||
|
//
|
||||||
|
var accumulator = new ResponsesStreamAccumulator();
|
||||||
|
await foreach (var serverSentEvent in streamRequestAsync(request, token))
|
||||||
|
{
|
||||||
|
var part = accumulator.Process(serverSentEvent);
|
||||||
|
if (part.HasContent)
|
||||||
|
yield return ToolCallingStreamEvent.TextDelta(new ContentStreamChunk(part.TextDelta, part.Sources));
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = accumulator.Build();
|
||||||
if (response is null)
|
if (response is null)
|
||||||
return null;
|
yield break;
|
||||||
|
|
||||||
this.lastResponse = response;
|
this.lastResponse = response;
|
||||||
return new ToolCallingRound(
|
yield return ToolCallingStreamEvent.RoundCompleted(new ToolCallingRound(
|
||||||
response.GetTextOutput(),
|
response.GetTextOutput(),
|
||||||
response.GetFunctionCalls()
|
response.GetFunctionCalls()
|
||||||
.Select(call => new ToolCallingRequestedCall(
|
.Select(call => new ToolCallingRequestedCall(
|
||||||
@ -69,7 +84,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
|
|||||||
!string.IsNullOrWhiteSpace(call.Name) && ToolExecutor.IsValidArgumentsJson(call.Arguments)))
|
!string.IsNullOrWhiteSpace(call.Name) && ToolExecutor.IsValidArgumentsJson(call.Arguments)))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
|
|
||||||
response.GetSources());
|
response.GetSources()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
38
app/MindWork AI Studio/Provider/ProviderJsonOptions.cs
Normal file
38
app/MindWork AI Studio/Provider/ProviderJsonOptions.cs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
using AIStudio.Provider.Anthropic;
|
||||||
|
using AIStudio.Provider.OpenAI;
|
||||||
|
|
||||||
|
namespace AIStudio.Provider;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The JSON options every provider request and response is read and written with.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// They sit outside the provider base class so that the types which interpret a stream can share
|
||||||
|
/// them without being a provider themselves. Those types are the ones worth testing, and a
|
||||||
|
/// provider cannot be constructed in a test at all -- it reaches for the service provider in its
|
||||||
|
/// constructor. Options rebuilt inside a test would be a second set of rules drifting away from
|
||||||
|
/// the one that actually reads the wire.
|
||||||
|
/// </remarks>
|
||||||
|
public static class ProviderJsonOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The shared options.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly JsonSerializerOptions OPTIONS = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
|
Converters =
|
||||||
|
{
|
||||||
|
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower),
|
||||||
|
new AnnotationConverter(),
|
||||||
|
new MessageBaseConverter(),
|
||||||
|
new SubContentConverter(),
|
||||||
|
new SubContentImageSourceConverter(),
|
||||||
|
new SubContentImageUrlConverter(),
|
||||||
|
},
|
||||||
|
AllowTrailingCommas = false
|
||||||
|
};
|
||||||
|
}
|
||||||
13
app/MindWork AI Studio/Provider/ServerSentEvent.cs
Normal file
13
app/MindWork AI Studio/Provider/ServerSentEvent.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
namespace AIStudio.Provider;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One event of a server-sent event stream, as it came off the wire.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The raw line travels next to its payload because not every decision can be made from the
|
||||||
|
/// payload alone: the Responses API, for one, ends its stream with an "event:" line which carries
|
||||||
|
/// no payload at all.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Line">The line as it arrived, including its "data:" prefix when it had one.</param>
|
||||||
|
/// <param name="Data">The payload of a data line, empty for every other kind of line.</param>
|
||||||
|
public readonly record struct ServerSentEvent(string Line, string Data);
|
||||||
@ -14,8 +14,14 @@ namespace AIStudio.Tools.ToolCallingSystem.Harness;
|
|||||||
public interface IToolCallingProviderAdapter
|
public interface IToolCallingProviderAdapter
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Executes one non-streamed round and returns what the model answered.
|
/// Executes one round and streams what the model answers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Every piece of text the model writes travels as a TEXT_DELTA event, including the text it
|
||||||
|
/// writes before it calls a tool. The round's outcome carries that text as well, but only so
|
||||||
|
/// that the loop can tell an answered round from a silent one -- whatever reaches the user
|
||||||
|
/// reaches them through the deltas, and through them only.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="finalResponseInstruction">
|
/// <param name="finalResponseInstruction">
|
||||||
/// When set, the instruction telling the model that no more tools are available. The adapter
|
/// When set, the instruction telling the model that no more tools are available. The adapter
|
||||||
/// appends it to the system prompt for this round only.
|
/// appends it to the system prompt for this round only.
|
||||||
@ -23,10 +29,12 @@ public interface IToolCallingProviderAdapter
|
|||||||
/// <param name="includeTools">Whether the tools may be offered in this round.</param>
|
/// <param name="includeTools">Whether the tools may be offered in this round.</param>
|
||||||
/// <param name="token">The cancellation token.</param>
|
/// <param name="token">The cancellation token.</param>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// The round's outcome, or null when the request failed. Null ends the loop without an error
|
/// The events of this round: any number of TEXT_DELTA events, closed by one ROUND_COMPLETED
|
||||||
/// message because the adapter has already told the user what went wrong.
|
/// event carrying the outcome. A stream which ends without that closing event is a failed
|
||||||
|
/// round; it ends the loop without an error message because the adapter has already told the
|
||||||
|
/// user what went wrong.
|
||||||
/// </returns>
|
/// </returns>
|
||||||
public Task<ToolCallingRound?> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default);
|
public IAsyncEnumerable<ToolCallingStreamEvent> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records the model's turn from the round just executed, so that the next round sees it.
|
/// Records the model's turn from the round just executed, so that the next round sees it.
|
||||||
|
|||||||
@ -19,6 +19,16 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
|
|||||||
private const string NO_ANSWER_AFTER_TOOL_CALL = "The model completed the tool call but did not return a final answer.";
|
private const string NO_ANSWER_AFTER_TOOL_CALL = "The model completed the tool call but did not return a final answer.";
|
||||||
private const string NO_ANSWER_AFTER_LIMIT = "The model did not return a final answer after completing the available tool calls.";
|
private const string NO_ANSWER_AFTER_LIMIT = "The model did not return a final answer after completing the available tool calls.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What separates the text of one round from the text of the next one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A model may write before it calls a tool and again after the result came back. Without a
|
||||||
|
/// separator, the last word of one round and the first of the next would run into each other,
|
||||||
|
/// since each round is a text of its own rather than a continuation.
|
||||||
|
/// </remarks>
|
||||||
|
private const string ROUND_TEXT_SEPARATOR = "\n\n";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async IAsyncEnumerable<ContentStreamChunk> RunAsync(
|
public async IAsyncEnumerable<ContentStreamChunk> RunAsync(
|
||||||
IToolCallingProviderAdapter adapter,
|
IToolCallingProviderAdapter adapter,
|
||||||
@ -28,6 +38,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
|
|||||||
var toolCallCount = 0;
|
var toolCallCount = 0;
|
||||||
var toolResultCharacterCount = 0L;
|
var toolResultCharacterCount = 0L;
|
||||||
var toolSources = new List<Source>();
|
var toolSources = new List<Source>();
|
||||||
|
var hasStreamedTextBefore = false;
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
@ -38,13 +49,52 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
|
|||||||
var finalResponseInstruction = ToolSelectionRules.GetToolCallsUnavailableInstruction(toolCallCount, toolResultCharacterCount);
|
var finalResponseInstruction = ToolSelectionRules.GetToolCallsUnavailableInstruction(toolCallCount, toolResultCharacterCount);
|
||||||
var finalResponseRequired = finalResponseInstruction is not null;
|
var finalResponseRequired = finalResponseInstruction is not null;
|
||||||
|
|
||||||
var round = await adapter.ExecuteRoundAsync(finalResponseInstruction, !finalResponseRequired, token);
|
ToolCallingRound? round = null;
|
||||||
|
var roundStreamedText = false;
|
||||||
|
|
||||||
|
//
|
||||||
|
// The model's words go out while the round is still running. That includes what it
|
||||||
|
// writes before a tool call -- "let me look that up" -- which used to be dropped on
|
||||||
|
// the floor because only the round's outcome was ever shown.
|
||||||
|
//
|
||||||
|
await foreach (var streamEvent in adapter.ExecuteRoundAsync(finalResponseInstruction, !finalResponseRequired, token))
|
||||||
|
{
|
||||||
|
if (streamEvent.Kind is ToolCallingStreamEventKind.ROUND_COMPLETED)
|
||||||
|
{
|
||||||
|
round = streamEvent.Round;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamEvent.Delta is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(streamEvent.Delta.Content))
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// The separator goes out once the new round actually has something to say:
|
||||||
|
// otherwise it would trail a round which only called a tool.
|
||||||
|
//
|
||||||
|
if (!roundStreamedText && hasStreamedTextBefore)
|
||||||
|
yield return new ContentStreamChunk(ROUND_TEXT_SEPARATOR, []);
|
||||||
|
|
||||||
|
roundStreamedText = true;
|
||||||
|
hasStreamedTextBefore = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return streamEvent.Delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// No outcome means the round failed: the request errored out, or the stream ended
|
||||||
|
// mid-sentence. Either way the adapter has already reported it.
|
||||||
|
//
|
||||||
if (round is null)
|
if (round is null)
|
||||||
{
|
{
|
||||||
await context.ResetToolRuntimeStatusAsync();
|
await context.ResetToolRuntimeStatusAsync();
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var roundAnswered = roundStreamedText || !string.IsNullOrWhiteSpace(round.TextOutput);
|
||||||
toolSources.MergeSources(round.Sources);
|
toolSources.MergeSources(round.Sources);
|
||||||
|
|
||||||
//
|
//
|
||||||
@ -65,8 +115,14 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
|
|||||||
if (finalResponseRequired)
|
if (finalResponseRequired)
|
||||||
{
|
{
|
||||||
await context.ResetToolRuntimeStatusAsync();
|
await context.ResetToolRuntimeStatusAsync();
|
||||||
|
|
||||||
|
//
|
||||||
|
// The answer itself is out already, so what is left to hand over are the sources
|
||||||
|
// the tools contributed. An empty chunk is how sources travel on their own; the
|
||||||
|
// streaming paths of the providers attach their annotations the same way.
|
||||||
|
//
|
||||||
yield return new ContentStreamChunk(
|
yield return new ContentStreamChunk(
|
||||||
string.IsNullOrWhiteSpace(round.TextOutput) ? NO_ANSWER_AFTER_LIMIT : round.TextOutput,
|
roundAnswered ? string.Empty : NO_ANSWER_AFTER_LIMIT,
|
||||||
[..toolSources]);
|
[..toolSources]);
|
||||||
|
|
||||||
yield break;
|
yield break;
|
||||||
@ -75,9 +131,9 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
|
|||||||
if (round.Calls.Count is 0)
|
if (round.Calls.Count is 0)
|
||||||
{
|
{
|
||||||
await context.ResetToolRuntimeStatusAsync();
|
await context.ResetToolRuntimeStatusAsync();
|
||||||
if (!string.IsNullOrWhiteSpace(round.TextOutput))
|
if (roundAnswered)
|
||||||
{
|
{
|
||||||
yield return new ContentStreamChunk(round.TextOutput, [..toolSources]);
|
yield return new ContentStreamChunk(string.Empty, [..toolSources]);
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
namespace AIStudio.Tools.ToolCallingSystem.Harness;
|
namespace AIStudio.Tools.ToolCallingSystem.Harness;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The outcome of one non-streamed round of a tool calling conversation, in a shape that no
|
/// The outcome of one round of a tool calling conversation, in a shape that no longer depends on
|
||||||
/// longer depends on the provider API it came from.
|
/// the provider API it came from.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="TextOutput">The text the model produced, empty when it only requested tool calls.</param>
|
/// <param name="TextOutput">
|
||||||
|
/// The text the model produced, empty when it only requested tool calls. The loop reads this to
|
||||||
|
/// tell an answered round from a silent one; it does not show it, because the very same text has
|
||||||
|
/// already reached the user as deltas while the round was running.
|
||||||
|
/// </param>
|
||||||
/// <param name="Calls">The tool calls the model requested, empty when it answered instead.</param>
|
/// <param name="Calls">The tool calls the model requested, empty when it answered instead.</param>
|
||||||
/// <param name="Sources">Sources the provider itself attached, such as those of a provider-native web search.</param>
|
/// <param name="Sources">Sources the provider itself attached, such as those of a provider-native web search.</param>
|
||||||
public sealed record ToolCallingRound(string TextOutput, IReadOnlyList<ToolCallingRequestedCall> Calls, IReadOnlyList<ISource> Sources);
|
public sealed record ToolCallingRound(string TextOutput, IReadOnlyList<ToolCallingRequestedCall> Calls, IReadOnlyList<ISource> Sources);
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
using AIStudio.Provider;
|
||||||
|
|
||||||
|
namespace AIStudio.Tools.ToolCallingSystem.Harness;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One event of a streamed round of a tool calling conversation.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Text arrives while the round is still running, its outcome only at the end. A round which ends
|
||||||
|
/// without a ROUND_COMPLETED event has failed: that is how a failed request or a truncated stream
|
||||||
|
/// is told apart from a round which simply had nothing to say. The adapter has already told the
|
||||||
|
/// user what went wrong in that case, so the loop ends without a message of its own.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Kind">What this event carries.</param>
|
||||||
|
/// <param name="Delta">The piece of text, set for TEXT_DELTA events only.</param>
|
||||||
|
/// <param name="Round">The round's outcome, set for ROUND_COMPLETED events only.</param>
|
||||||
|
public sealed record ToolCallingStreamEvent(ToolCallingStreamEventKind Kind, ContentStreamChunk? Delta, ToolCallingRound? Round)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an event for a piece of text, along with the sources it brought.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="delta">The chunk to show.</param>
|
||||||
|
public static ToolCallingStreamEvent TextDelta(ContentStreamChunk delta) => new(ToolCallingStreamEventKind.TEXT_DELTA, delta, null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an event for a piece of text without any sources.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">The text to show.</param>
|
||||||
|
public static ToolCallingStreamEvent TextDelta(string text) => new(ToolCallingStreamEventKind.TEXT_DELTA, new ContentStreamChunk(text, []), null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the event which ends a round.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="round">The round's outcome.</param>
|
||||||
|
public static ToolCallingStreamEvent RoundCompleted(ToolCallingRound round) => new(ToolCallingStreamEventKind.ROUND_COMPLETED, null, round);
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
namespace AIStudio.Tools.ToolCallingSystem.Harness;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What one event of a streamed tool calling round carries.
|
||||||
|
/// </summary>
|
||||||
|
public enum ToolCallingStreamEventKind
|
||||||
|
{
|
||||||
|
NONE = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A piece of text the model wrote, to be shown while the round is still running.
|
||||||
|
/// </summary>
|
||||||
|
TEXT_DELTA,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The round is over and the event carries its outcome.
|
||||||
|
/// </summary>
|
||||||
|
ROUND_COMPLETED,
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
# v26.9.1, build 256 (2026-09-xx xx:xx UTC)
|
# v26.9.1, build 256 (2026-09-xx xx:xx UTC)
|
||||||
- Added tools that AI models can use on their own, starting with Web Search and Read Web Page. When you ask something a model cannot answer from what it knows, it now searches the web, reads the pages it found, and answers with the sources it used. You decide which tools a model may use, right below the message field, and you can watch it work: AI Studio shows which tool is running and, afterward, every call it made with its result. Whether tools are offered at all depends on the model because it has to support them. Read Web Page works right away; for Web Search you pick a search service in the app settings — Tavily or Staan with a free API key, or a SearXNG instance you run yourself. Set up more than one, and they can take turns when one of them finds nothing, or be asked all at once with their results combined. Many thanks to Peer Schütt (`peerschuett`) and Nils Kruthoff (`nilskruthoff`) for building this feature.
|
- Added tools that AI models can use on their own, starting with Web Search and Read Web Page. When you ask something a model cannot answer from what it knows, it now searches the web, reads the pages it found, and answers with the sources it used. You decide which tools a model may use, right below the message field, and you can watch it work: AI Studio shows which tool is running and, afterward, every call it made with its result. Whether tools are offered at all depends on the model because it has to support them. Read Web Page works right away; for Web Search you pick a search service in the app settings — Tavily or Staan with a free API key, or a SearXNG instance you run yourself. Set up more than one, and they can take turns when one of them finds nothing, or be asked all at once with their results combined. Many thanks to Peer Schütt (`peerschuett`) and Nils Kruthoff (`nilskruthoff`) for building this feature.
|
||||||
|
- Added answers that appear word by word even while the AI uses its tools. You read along as the model writes, including the short note it puts down before it looks something up, and the answer that follows a tool call arrives the same way instead of all at once at the end.
|
||||||
- Added safeguards around everything these tools bring back. Anything fetched from the web is treated as untrusted: AI Studio removes instructions hidden in a page before a model reads it and tells you when it did, exactly as it already does for the documents and web pages you load yourself. A model can never point a tool at your own network. Each tool states how much you have to trust a provider before it may be used with it, so your questions do not travel further than you allow. You can adjust that requirement per tool in the app settings.
|
- Added safeguards around everything these tools bring back. Anything fetched from the web is treated as untrusted: AI Studio removes instructions hidden in a page before a model reads it and tells you when it did, exactly as it already does for the documents and web pages you load yourself. A model can never point a tool at your own network. Each tool states how much you have to trust a provider before it may be used with it, so your questions do not travel further than you allow. You can adjust that requirement per tool in the app settings.
|
||||||
- Added tools to the assistants. Each assistant has its own tool settings: which tools it starts with and whether you get to change them while you work. The chat, the coding assistant, and the Slide Builder always show the selection; for every other assistant you switch it on where you want it.
|
- Added tools to the assistants. Each assistant has its own tool settings: which tools it starts with and whether you get to change them while you work. The chat, the coding assistant, and the Slide Builder always show the selection; for every other assistant you switch it on where you want it.
|
||||||
- Added tools to the Batch Processing assistant, so a batch run can look things up while it works through your documents. You choose them next to the instructions of the job, and every document is processed with the same set. The log file now records which tools were used for each document, and whether a call failed or was blocked, so you can tell how an answer came about.
|
- Added tools to the Batch Processing assistant, so a batch run can look things up while it works through your documents. You choose them next to the instructions of the job, and every document is processed with the same set. The log file now records which tools were used for each document, and whether a call failed or was blocked, so you can tell how an answer came about.
|
||||||
|
|||||||
@ -0,0 +1,238 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
using AIStudio.Provider;
|
||||||
|
using AIStudio.Provider.Anthropic;
|
||||||
|
|
||||||
|
namespace AIStudio.Tests.Provider.ToolCalling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks how a streamed Anthropic message is put back together.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The blocks of a message do not only have to be readable afterwards, they have to be sendable:
|
||||||
|
/// they go back to Anthropic with the next round. A thinking block is the sharp edge -- its
|
||||||
|
/// signature has to return byte for byte with the text it was made for, or the provider refuses
|
||||||
|
/// the continuation with a 400 and the whole conversation is stuck.
|
||||||
|
/// </remarks>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class AnthropicMessageStreamAccumulatorTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void ATextBlockIsTheFragmentsItArrivedIn()
|
||||||
|
{
|
||||||
|
var response = Read(
|
||||||
|
"""{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Let me "}}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"look that "}}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"up."}}""",
|
||||||
|
"""{"type":"content_block_stop","index":0}""",
|
||||||
|
"""{"type":"message_stop"}""");
|
||||||
|
|
||||||
|
Assert.That(response!.GetTextOutput(), Is.EqualTo("Let me look that up."), "The fragments are joined in order and with nothing in between.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TheTextIsShownWhileItIsBeingWritten()
|
||||||
|
{
|
||||||
|
var accumulator = new AnthropicMessageStreamAccumulator();
|
||||||
|
var shown = string.Concat(Lines(
|
||||||
|
"""{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel"}}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"lo"}}""")
|
||||||
|
.Select(accumulator.Process)
|
||||||
|
.Where(part => part.HasContent)
|
||||||
|
.Select(part => part.TextDelta));
|
||||||
|
|
||||||
|
Assert.That(shown, Is.EqualTo("Hello"), "Each piece of text goes out as it arrives rather than at the end of the block.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ThinkingNeverReachesTheUser()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// Neither path has ever shown thinking, and making it visible would be a feature of its
|
||||||
|
// own rather than something that happens by accident while streaming.
|
||||||
|
//
|
||||||
|
var accumulator = new AnthropicMessageStreamAccumulator();
|
||||||
|
var shown = Lines(
|
||||||
|
"""{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me consider this."}}""")
|
||||||
|
.Select(accumulator.Process)
|
||||||
|
.Any(part => part.HasContent);
|
||||||
|
|
||||||
|
Assert.That(shown, Is.False, "What the model thinks stays between it and the next round.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void AThinkingBlockKeepsItsSignature()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// The test which nails down the sharpest risk of this change: text and signature have to
|
||||||
|
// come back exactly as they were sent, or the next round is refused.
|
||||||
|
//
|
||||||
|
var response = Read(
|
||||||
|
"""{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Weighing "}}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"the options."}}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"EqQBCgIYAhIM+abc/DEF=="}}""",
|
||||||
|
"""{"type":"content_block_stop","index":0}""",
|
||||||
|
"""{"type":"message_stop"}""");
|
||||||
|
|
||||||
|
var block = response!.Content.Single();
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(ReadString(block, "type"), Is.EqualTo("thinking"), "The block goes back as the kind it was.");
|
||||||
|
Assert.That(ReadString(block, "thinking"), Is.EqualTo("Weighing the options."), "With the thinking it carried.");
|
||||||
|
Assert.That(ReadString(block, "signature"), Is.EqualTo("EqQBCgIYAhIM+abc/DEF=="), "And with the signature that was made for exactly that text.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ARedactedThinkingBlockGoesBackUntouched()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// We cannot read it, which is the very reason we must not rewrite it either.
|
||||||
|
//
|
||||||
|
var response = Read(
|
||||||
|
"""{"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"EroBCkYIARgCKkBS0mBXJ"}}""",
|
||||||
|
"""{"type":"content_block_stop","index":0}""",
|
||||||
|
"""{"type":"message_stop"}""");
|
||||||
|
|
||||||
|
Assert.That(ReadString(response!.Content.Single(), "data"), Is.EqualTo("EroBCkYIARgCKkBS0mBXJ"), "Whatever we do not understand travels on unchanged.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void AToolUseCollectsItsArgumentsFromFragments()
|
||||||
|
{
|
||||||
|
var response = Read(
|
||||||
|
"""{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"web_search","input":{}}}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":"}}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"weather\"}"}}""",
|
||||||
|
"""{"type":"content_block_stop","index":0}""",
|
||||||
|
"""{"type":"message_stop"}""");
|
||||||
|
|
||||||
|
var toolUse = response!.GetToolUses().Single();
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(toolUse.Id, Is.EqualTo("toolu_1"), "The ID comes from the block as it opened.");
|
||||||
|
Assert.That(toolUse.Name, Is.EqualTo("web_search"), "So does the name.");
|
||||||
|
Assert.That(toolUse.Arguments, Is.EqualTo("""{"query":"weather"}"""), "And the arguments are the fragments joined back together.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void AToolWithoutArgumentsGetsAnEmptyObject()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// Anthropic sends no fragment at all for a tool which takes nothing, and the input field
|
||||||
|
// has to be an object either way.
|
||||||
|
//
|
||||||
|
var response = Read(
|
||||||
|
"""{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"get_time","input":{}}}""",
|
||||||
|
"""{"type":"content_block_stop","index":0}""",
|
||||||
|
"""{"type":"message_stop"}""");
|
||||||
|
|
||||||
|
Assert.That(response!.GetToolUses().Single().Arguments, Is.EqualTo("{}"), "An empty object is what an empty call looks like on the wire.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ArgumentsWhichNeverParsedMakeTheCallInvalidWhileTheBlockStaysWellFormed()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// Two things have to be true at once here: the provider gets a block it accepts, and the
|
||||||
|
// call is rejected rather than run with arguments the model never finished writing.
|
||||||
|
//
|
||||||
|
var response = Read(
|
||||||
|
"""{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"web_search","input":{}}}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"wea"}}""",
|
||||||
|
"""{"type":"content_block_stop","index":0}""",
|
||||||
|
"""{"type":"message_stop"}""");
|
||||||
|
|
||||||
|
var toolUse = response!.GetToolUses().Single();
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(toolUse.Arguments, Is.EqualTo("""{"query":"wea"""), "The call carries what actually arrived, which no tool executor will accept.");
|
||||||
|
Assert.That(ReadRawText(response.Content.Single(), "input"), Is.EqualTo("{}"), "While the block going back to Anthropic carries an object, because anything else would be refused.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ABlockWhoseClosingEventNeverCameIsStillFinished()
|
||||||
|
{
|
||||||
|
var response = Read(
|
||||||
|
"""{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}""",
|
||||||
|
"""{"type":"message_stop"}""");
|
||||||
|
|
||||||
|
Assert.That(response!.GetTextOutput(), Is.EqualTo("Hello"), "The end of the message ends every block it still has open.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void BlocksComeBackInTheOrderTheyWereIndexed()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// Interleaved on purpose: what decides the order is the index, not the moment a block
|
||||||
|
// happened to be closed.
|
||||||
|
//
|
||||||
|
var response = Read(
|
||||||
|
"""{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}""",
|
||||||
|
"""{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"web_search","input":{}}}""",
|
||||||
|
"""{"type":"content_block_stop","index":1}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"First"}}""",
|
||||||
|
"""{"type":"content_block_stop","index":0}""",
|
||||||
|
"""{"type":"message_stop"}""");
|
||||||
|
|
||||||
|
Assert.That(response!.Content.Select(block => ReadString(block, "type")), Is.EqualTo(new[] { "text", "tool_use" }), "The order of a message is the order of its indices.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void AMessageWhichOnlyEndedWithAStopReasonCountsAsFinished()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// Not every gateway closes with the message stop event, so the stop reason ends the
|
||||||
|
// message as well.
|
||||||
|
//
|
||||||
|
var response = Read(
|
||||||
|
"""{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}""",
|
||||||
|
"""{"type":"content_block_stop","index":0}""",
|
||||||
|
"""{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}""");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(response, Is.Not.Null, "A message with a stop reason is a message which ended.");
|
||||||
|
Assert.That(response!.StopReason, Is.EqualTo("end_turn"), "And the reason it ended travels with it.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void AStreamCutOffMidSentenceIsAFailedRound()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// No stop event and no stop reason: whatever was streamed stays on screen, but there is
|
||||||
|
// no round to continue from.
|
||||||
|
//
|
||||||
|
var response = Read(
|
||||||
|
"""{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}""",
|
||||||
|
"""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel"}}""");
|
||||||
|
|
||||||
|
Assert.That(response, Is.Null, "An unfinished message is not handed on as if it were finished.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AnthropicResponse? Read(params string[] data)
|
||||||
|
{
|
||||||
|
var accumulator = new AnthropicMessageStreamAccumulator();
|
||||||
|
foreach (var serverSentEvent in Lines(data))
|
||||||
|
accumulator.Process(serverSentEvent);
|
||||||
|
|
||||||
|
return accumulator.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<ServerSentEvent> Lines(params string[] data) => data.Select(Event);
|
||||||
|
|
||||||
|
private static ServerSentEvent Event(string data) => new($"data: {data}", data);
|
||||||
|
|
||||||
|
private static string ReadString(JsonElement block, string propertyName) => block.TryGetProperty(propertyName, out var property) ? property.GetString() ?? string.Empty : string.Empty;
|
||||||
|
|
||||||
|
private static string ReadRawText(JsonElement block, string propertyName) => block.TryGetProperty(propertyName, out var property) ? property.GetRawText() : string.Empty;
|
||||||
|
}
|
||||||
@ -0,0 +1,199 @@
|
|||||||
|
using AIStudio.Provider;
|
||||||
|
using AIStudio.Tools;
|
||||||
|
using AIStudio.Provider.OpenAI;
|
||||||
|
|
||||||
|
namespace AIStudio.Tests.Provider.ToolCalling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks how a streamed Chat Completions answer is put back together.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Seventeen providers share this one path, and they disagree on nearly every detail of it: some
|
||||||
|
/// send the index with every fragment, some only with the first, some send no index at all, and
|
||||||
|
/// not all of them close the stream with a "[DONE]". Each of those is one case below, because
|
||||||
|
/// each of them is one provider whose tool calls would otherwise fall apart.
|
||||||
|
/// </remarks>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class ChatCompletionToolCallAccumulatorTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void ArgumentsSpreadOverManyFragmentsBecomeOneCall()
|
||||||
|
{
|
||||||
|
var message = Read(
|
||||||
|
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"web_search","arguments":"{\"qu"}}]}}]}""",
|
||||||
|
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ery\":"}}]}}]}""",
|
||||||
|
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"wea"}}]}}]}""",
|
||||||
|
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ther\""}}]}}]}""",
|
||||||
|
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"}"}}]}}]}""",
|
||||||
|
"[DONE]");
|
||||||
|
|
||||||
|
var call = message!.ToolCalls!.Single()!;
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(call.Id, Is.EqualTo("call_1"), "The ID arrived with the first fragment and belongs to the whole call.");
|
||||||
|
Assert.That(call.Function!.Name, Is.EqualTo("web_search"), "So does the name.");
|
||||||
|
Assert.That(call.Function!.Arguments, Is.EqualTo("""{"query":"weather"}"""), "And the arguments are the fragments in the order they came, joined without anything in between.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TwoCallsWrittenAtTheSameTimeStayApart()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// Nothing says a model finishes one call before it starts the next, and the index is
|
||||||
|
// what keeps the fragments of the two from running into each other.
|
||||||
|
//
|
||||||
|
var message = Read(
|
||||||
|
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"web_search","arguments":"{\"query\":"}}]}}]}""",
|
||||||
|
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_b","function":{"name":"read_web_page","arguments":"{\"url\":"}}]}}]}""",
|
||||||
|
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"a\"}"}}]}}]}""",
|
||||||
|
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\"b\"}"}}]}}]}""");
|
||||||
|
|
||||||
|
Assert.That(message!.ToolCalls!.Select(x => $"{x!.Id}:{x.Function!.Arguments}"), Is.EqualTo(new[]
|
||||||
|
{
|
||||||
|
"""call_a:{"query":"a"}""",
|
||||||
|
"""call_b:{"url":"b"}""",
|
||||||
|
}), "Each call collects its own fragments, whichever order they arrive in.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void AProviderWhichSendsNoIndexStillGetsAWholeCall()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// Some gateways leave the index out once the call is open. What is left to correlate by
|
||||||
|
// is the ID, and after that the call which was opened last.
|
||||||
|
//
|
||||||
|
var message = Read(
|
||||||
|
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","function":{"name":"web_search","arguments":"{\"query\":"}}]}}]}""",
|
||||||
|
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"function":{"arguments":"\"weather\"}"}}]}}]}""");
|
||||||
|
|
||||||
|
var call = message!.ToolCalls!.Single()!;
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(call.Id, Is.EqualTo("call_1"), "One call, not two: a fragment without an index belongs to the one being written.");
|
||||||
|
Assert.That(call.Function!.Arguments, Is.EqualTo("""{"query":"weather"}"""), "And its arguments are complete.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void AWholeCallInOneFragmentWorksJustAsWell()
|
||||||
|
{
|
||||||
|
var message = Read("""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"web_search","arguments":"{\"query\":\"weather\"}"}}]},"finish_reason":"tool_calls"}]}""");
|
||||||
|
|
||||||
|
var call = message!.ToolCalls!.Single()!;
|
||||||
|
Assert.That(call.Function!.Arguments, Is.EqualTo("""{"query":"weather"}"""), "Fragmenting is what providers may do, not what they must do.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TextAndAToolCallInTheSameRoundBothSurvive()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// The preamble case on the wire: the model says what it is going to do and then does it.
|
||||||
|
//
|
||||||
|
var accumulator = new ChatCompletionToolCallAccumulator();
|
||||||
|
var shown = string.Concat(Lines(
|
||||||
|
"""{"choices":[{"index":0,"delta":{"content":"Let me look "}}]}""",
|
||||||
|
"""{"choices":[{"index":0,"delta":{"content":"that up."}}]}""",
|
||||||
|
"""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"web_search","arguments":"{}"}}]}}]}""")
|
||||||
|
.Select(accumulator.Process)
|
||||||
|
.Where(part => part.HasContent)
|
||||||
|
.Select(part => part.TextDelta));
|
||||||
|
|
||||||
|
var message = accumulator.Build();
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(shown, Is.EqualTo("Let me look that up."), "The text goes out while it is being written, in the pieces it arrives in.");
|
||||||
|
Assert.That(message!.Content, Is.EqualTo("Let me look that up."), "And the same text goes back to the provider as what the model said.");
|
||||||
|
Assert.That(message.ToolCalls!.Single()!.Id, Is.EqualTo("call_1"), "The tool call of that round is there as well.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ContentSentAsPartsIsReadAsText()
|
||||||
|
{
|
||||||
|
// Some gateways send the content the way a request carries it, as a list of parts:
|
||||||
|
var message = Read("""{"choices":[{"index":0,"delta":{"content":[{"type":"text","text":"Hello"}]}}]}""");
|
||||||
|
|
||||||
|
Assert.That(message!.Content, Is.EqualTo("Hello"), "A provider which sends parts instead of a string is still sending text.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ReasoningTravelsSeparatelyFromTheAnswer()
|
||||||
|
{
|
||||||
|
var message = Read(
|
||||||
|
"""{"choices":[{"index":0,"delta":{"reasoning_content":"Thinking about it."}}]}""",
|
||||||
|
"""{"choices":[{"index":0,"delta":{"content":"The answer."}}]}""");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(message!.ReasoningContent, Is.EqualTo("Thinking about it."), "Reasoning is kept, because the next request is charged for it.");
|
||||||
|
Assert.That(message.Content, Is.EqualTo("The answer."), "And it is not mixed into the answer.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void AToolWhichTakesNothingGetsAnEmptyObject()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// A parameterless tool is called without a single argument fragment, while the very same
|
||||||
|
// call carries an empty object when it is not streamed. Handing on the empty string here
|
||||||
|
// would have every one of those calls rejected as invalid.
|
||||||
|
//
|
||||||
|
var withoutAnyFragment = Read("""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_time"}}]}}]}""");
|
||||||
|
var withAnEmptyFragment = Read("""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_time","arguments":""}}]}}]}""");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(withoutAnyFragment!.ToolCalls!.Single()!.Function!.Arguments, Is.EqualTo("{}"), "No fragment at all is a call without arguments, not a broken one.");
|
||||||
|
Assert.That(withAnEmptyFragment!.ToolCalls!.Single()!.Function!.Arguments, Is.EqualTo("{}"), "And neither is the empty fragment some providers send instead.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ARoundWithoutTextHasNoContentAtAll()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// An empty string in place of the missing content is rejected by some providers, so the
|
||||||
|
// field has to be absent exactly as it is in a non-streamed answer.
|
||||||
|
//
|
||||||
|
var message = Read("""{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"web_search","arguments":"{}"}}]}}]}""");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(message!.RawContent, Is.Null, "No text means no content field.");
|
||||||
|
Assert.That(message.Content, Is.Null, "Which is what the adapter reads as an answer without words.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void AStreamWhichSaidNothingIsAFailedRound()
|
||||||
|
{
|
||||||
|
Assert.That(new ChatCompletionToolCallAccumulator().Build(), Is.Null, "A request that failed leaves no lines behind, and a round without a message ends the loop without a second error message.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void SourcesOfTheProviderTravelWithTheirLine()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// Perplexity puts its search results next to the text rather than on a line of their own,
|
||||||
|
// which is why the sources are read through the provider's own types.
|
||||||
|
//
|
||||||
|
var accumulator = new ChatCompletionToolCallAccumulator(_ => [new Source("Example", "https://example.org/", SourceOrigin.LLM)]);
|
||||||
|
var part = accumulator.Process(Event("""{"choices":[{"index":0,"delta":{"content":"Hello"}}]}"""));
|
||||||
|
|
||||||
|
Assert.That(part.Sources.Select(x => x.URL), Is.EqualTo(new[] { "https://example.org/" }), "Whatever the provider announced on that line reaches the user with it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatCompletionResponseMessage? Read(params string[] data)
|
||||||
|
{
|
||||||
|
var accumulator = new ChatCompletionToolCallAccumulator();
|
||||||
|
foreach (var serverSentEvent in Lines(data))
|
||||||
|
accumulator.Process(serverSentEvent);
|
||||||
|
|
||||||
|
return accumulator.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<ServerSentEvent> Lines(params string[] data) => data.Select(Event);
|
||||||
|
|
||||||
|
private static ServerSentEvent Event(string data) => new($"data: {data}", data);
|
||||||
|
}
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
using AIStudio.Provider;
|
||||||
|
using AIStudio.Provider.OpenAI;
|
||||||
|
|
||||||
|
namespace AIStudio.Tests.Provider.ToolCalling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks how a streamed Responses API call is read back.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The API repeats the whole response when it is done, so there is little to reassemble here --
|
||||||
|
/// but there is one thing to get right: the reasoning items have to return exactly as they came,
|
||||||
|
/// including the parts we do not understand. The API refuses a continuation whose reasoning is
|
||||||
|
/// missing, and it would just as surely refuse one we rewrote.
|
||||||
|
/// </remarks>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class ResponsesStreamAccumulatorTests
|
||||||
|
{
|
||||||
|
private const string REASONING_ITEM = """{"type":"reasoning","id":"rs_1","summary":[],"encrypted_content":"gAAAAAB0aXRs"}""";
|
||||||
|
private const string COMPLETED_PREFIX = """{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5","output":[""";
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TheReasoningItemComesBackWordForWord()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// The test that nails down the main risk: whatever the reasoning item carries, including
|
||||||
|
// fields nobody here knows about, is what goes back on the next request.
|
||||||
|
//
|
||||||
|
var response = Read(COMPLETED_PREFIX + REASONING_ITEM + "]}}");
|
||||||
|
|
||||||
|
Assert.That(response!.Output.Single().GetRawText(), Is.EqualTo(REASONING_ITEM), "Not a field added, not a field dropped: the item travels on as it arrived.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TheCompletedEventCarriesTheWholeRound()
|
||||||
|
{
|
||||||
|
var response = Read(COMPLETED_PREFIX + REASONING_ITEM + "," +
|
||||||
|
"""{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Here is the answer."}]},""" +
|
||||||
|
"""{"type":"function_call","call_id":"call_1","name":"web_search","arguments":"{\"query\":\"weather\"}"}""" +
|
||||||
|
"]}}");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(response!.GetTextOutput(), Is.EqualTo("Here is the answer."), "The text of the round is read out of the completed response.");
|
||||||
|
Assert.That(response.GetFunctionCalls().Single().CallId, Is.EqualTo("call_1"), "And so are the calls it asked for.");
|
||||||
|
Assert.That(response.Output, Has.Count.EqualTo(3), "Every output item is kept, because every one of them goes back.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TheTextIsShownWhileItIsBeingWritten()
|
||||||
|
{
|
||||||
|
var accumulator = new ResponsesStreamAccumulator();
|
||||||
|
var shown = string.Concat(Lines(
|
||||||
|
"""{"type":"response.output_text.delta","delta":"Let me "}""",
|
||||||
|
"""{"type":"response.output_text.delta","delta":"look that up."}""")
|
||||||
|
.Select(accumulator.Process)
|
||||||
|
.Where(part => part.HasContent)
|
||||||
|
.Select(part => part.TextDelta));
|
||||||
|
|
||||||
|
Assert.That(shown, Is.EqualTo("Let me look that up."), "Each piece of text goes out as it arrives rather than at the end of the round.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void AnAnnouncedSourceTravelsWithItsLine()
|
||||||
|
{
|
||||||
|
var accumulator = new ResponsesStreamAccumulator();
|
||||||
|
var part = accumulator.Process(Event("""{"type":"response.output_text.annotation.added","annotation_index":0,"annotation":{"type":"url_citation","title":"Example","url":"https://example.org/"}}"""));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(part.Sources.Select(x => x.URL), Is.EqualTo(new[] { "https://example.org/" }), "A citation reaches the user as soon as the model makes it.");
|
||||||
|
Assert.That(part.TextDelta, Is.Empty, "A line which only announces a source carries no text.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void AGatewayWithoutACompletedEventStillGetsARound()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// Not every gateway in front of this API sends the closing event. The finished output
|
||||||
|
// items are enough to put the round back together, reasoning included.
|
||||||
|
//
|
||||||
|
var response = Read(
|
||||||
|
"""{"type":"response.output_item.done","output_index":0,"item":""" + REASONING_ITEM + "}",
|
||||||
|
"""{"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_1","name":"web_search","arguments":"{}"}}""");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(response, Is.Not.Null, "A round built from its items is still a round.");
|
||||||
|
Assert.That(response!.Output.First().GetRawText(), Is.EqualTo(REASONING_ITEM), "And the reasoning item is as untouched as it would be in the completed event.");
|
||||||
|
Assert.That(response.GetFunctionCalls().Single().Name, Is.EqualTo("web_search"), "The call is there to be executed.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TheCompletedEventWinsOverTheCollectedItems()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// When both arrive, the response the API itself assembled is the one to trust.
|
||||||
|
//
|
||||||
|
var response = Read(
|
||||||
|
"""{"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Partial"}]}}""",
|
||||||
|
"""{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Complete"}]}]}}""");
|
||||||
|
|
||||||
|
Assert.That(response!.GetTextOutput(), Is.EqualTo("Complete"), "The closing event is the round, and the collected items were only there in case it never came.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void AStreamWhichSaidNothingIsAFailedRound()
|
||||||
|
{
|
||||||
|
var response = Read("""{"type":"response.created","response":{"id":"resp_1"}}""");
|
||||||
|
|
||||||
|
Assert.That(response, Is.Null, "Neither a completed response nor a single finished item: there is no round here to continue from.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponsesResponse? Read(params string[] data)
|
||||||
|
{
|
||||||
|
var accumulator = new ResponsesStreamAccumulator();
|
||||||
|
foreach (var serverSentEvent in Lines(data))
|
||||||
|
accumulator.Process(serverSentEvent);
|
||||||
|
|
||||||
|
return accumulator.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<ServerSentEvent> Lines(params string[] data) => data.Select(Event);
|
||||||
|
|
||||||
|
private static ServerSentEvent Event(string data) => new($"data: {data}", data);
|
||||||
|
}
|
||||||
325
app/Tests/Tools/ToolCalling/ToolCallingLoopTests.cs
Normal file
325
app/Tests/Tools/ToolCalling/ToolCallingLoopTests.cs
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
using AIStudio.Provider;
|
||||||
|
using AIStudio.Tools;
|
||||||
|
using AIStudio.Tools.ToolCallingSystem;
|
||||||
|
using AIStudio.Tools.ToolCallingSystem.Harness;
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace AIStudio.Tests.Tools.ToolCalling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks what the tool calling loop puts on screen while a model works through its tools.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Two things decide whether this loop behaves: every word the model writes has to arrive, and it
|
||||||
|
/// has to arrive once. Both used to be free -- the round's text was shown at its end, and there
|
||||||
|
/// was nothing else it could have come from. Now the text streams out while the round runs and
|
||||||
|
/// the round still reports it afterwards, so the one thing that must never happen is showing it
|
||||||
|
/// twice. The other side of the same coin is the preamble a model writes before it calls a tool,
|
||||||
|
/// which was dropped entirely before and is the reason for this whole change.<br/><br/>
|
||||||
|
/// The adapter is scripted rather than real: what a provider puts on the wire is checked in the
|
||||||
|
/// accumulator tests, while this is about the loop in between.
|
||||||
|
/// </remarks>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class ToolCallingLoopTests
|
||||||
|
{
|
||||||
|
private const string PREAMBLE = "Let me look that up.";
|
||||||
|
private const string ANSWER = "Here is the answer.";
|
||||||
|
private const string SEPARATOR = "\n\n";
|
||||||
|
private const string NO_ANSWER = "did not return a final answer";
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task APreambleReachesTheUserAlthoughItsRoundOnlyCalledATool()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// The regression this whole change is about: a model which says what it is about to do
|
||||||
|
// before it does it. That sentence never left the provider layer.
|
||||||
|
//
|
||||||
|
var adapter = new ScriptedAdapter(
|
||||||
|
[Text(PREAMBLE), Completed(PREAMBLE, [Call("call-1")])],
|
||||||
|
[Text(ANSWER), Completed(ANSWER)]);
|
||||||
|
|
||||||
|
var written = await Run(adapter);
|
||||||
|
|
||||||
|
Assert.That(written, Does.StartWith(PREAMBLE), "What the model says before it calls a tool is the first thing the user reads, not something we keep to ourselves.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task EveryTextIsWrittenExactlyOnce()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// The one way this can go wrong: the round reports the same text its deltas already
|
||||||
|
// carried, and the answer ends up on screen twice.
|
||||||
|
//
|
||||||
|
var adapter = new ScriptedAdapter(
|
||||||
|
[Text(PREAMBLE), Completed(PREAMBLE, [Call("call-1")])],
|
||||||
|
[Text(ANSWER), Completed(ANSWER)]);
|
||||||
|
|
||||||
|
var written = await Run(adapter);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(Occurrences(written, PREAMBLE), Is.EqualTo(1), "The preamble streamed out; the round reporting it again must not put it on screen a second time.");
|
||||||
|
Assert.That(Occurrences(written, ANSWER), Is.EqualTo(1), "The same goes for the final answer, which is where a duplicate would be most visible.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task OnlyARoundWhichSpeaksGetsASeparator()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// A round which does nothing but call a tool must not leave a gap behind: the separator
|
||||||
|
// belongs between two texts, not after every round.
|
||||||
|
//
|
||||||
|
var afterSpeaking = await Run(new ScriptedAdapter(
|
||||||
|
[Text(PREAMBLE), Completed(PREAMBLE, [Call("call-1")])],
|
||||||
|
[Text(ANSWER), Completed(ANSWER)]));
|
||||||
|
|
||||||
|
var afterSilence = await Run(new ScriptedAdapter(
|
||||||
|
[Completed(string.Empty, [Call("call-1")])],
|
||||||
|
[Text(ANSWER), Completed(ANSWER)]));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(afterSpeaking, Is.EqualTo($"{PREAMBLE}{SEPARATOR}{ANSWER}"), "Two texts from two rounds are two paragraphs, not one run-on sentence.");
|
||||||
|
Assert.That(afterSilence, Is.EqualTo(ANSWER), "Nothing was said before, so there is nothing to separate from.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TheLimitMessageOnlyAppearsWhenTheLastRoundSaidNothing()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// Reaching the limit means the model is asked for a final answer without tools. When it
|
||||||
|
// gives one, that answer has already streamed out -- and the message about not having
|
||||||
|
// answered has to stay away.
|
||||||
|
//
|
||||||
|
var answering = await Run(new ScriptedAdapter([..ExhaustTheToolBudget(), [Text(ANSWER), Completed(ANSWER)]]));
|
||||||
|
var silent = await Run(new ScriptedAdapter([..ExhaustTheToolBudget(), [Completed(string.Empty)]]));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(answering, Does.EndWith(ANSWER).And.Not.Contains(NO_ANSWER), "The model answered, so nothing has to be said on its behalf.");
|
||||||
|
Assert.That(silent, Does.Contain(NO_ANSWER), "It stayed silent after using up its tools, and silence would look like a hung request.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TheNoAnswerMessageOnlyAppearsWhenTheRoundSaidNothing()
|
||||||
|
{
|
||||||
|
var answering = await Run(new ScriptedAdapter(
|
||||||
|
[Completed(string.Empty, [Call("call-1")])],
|
||||||
|
[Text(ANSWER), Completed(ANSWER)]));
|
||||||
|
|
||||||
|
var silent = await Run(new ScriptedAdapter(
|
||||||
|
[Completed(string.Empty, [Call("call-1")])],
|
||||||
|
[Completed(string.Empty)]));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(answering, Is.EqualTo(ANSWER), "There is an answer, so the fallback message has no place here.");
|
||||||
|
Assert.That(silent, Does.Contain(NO_ANSWER), "The tool ran and nothing came of it, which the user has to be told.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TheSourcesArriveAlthoughTheFinalTextNoLongerDoes()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// The last round hands over an empty chunk carrying the sources, because its text went
|
||||||
|
// out as deltas. Forget that chunk and the citation links of a web search disappear.
|
||||||
|
//
|
||||||
|
var source = new Source("Example", "https://example.org/", SourceOrigin.LLM);
|
||||||
|
var adapter = new ScriptedAdapter(
|
||||||
|
[Completed(string.Empty, [Call("call-1")], [source])],
|
||||||
|
[Text(ANSWER), Completed(ANSWER)]);
|
||||||
|
|
||||||
|
var chunks = await Collect(adapter);
|
||||||
|
|
||||||
|
Assert.That(chunks.SelectMany(chunk => chunk.Sources).Select(x => x.URL), Does.Contain("https://example.org/"), "The sources of a round reach the caller even when its text does not.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ARoundWhichNeverCompletesEndsQuietly()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// A stream cut off mid-sentence, or a request which failed: the adapter has told the user
|
||||||
|
// what went wrong already, so the loop adds nothing of its own.
|
||||||
|
//
|
||||||
|
var adapter = new ScriptedAdapter([Text(PREAMBLE)]);
|
||||||
|
|
||||||
|
var chunks = await Collect(adapter);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(string.Concat(chunks.Select(x => x.Content)), Is.EqualTo(PREAMBLE), "What was streamed stays; nothing is taken back.");
|
||||||
|
Assert.That(chunks.Select(x => x.Content), Has.None.Contains(NO_ANSWER), "An error message on top of the adapter's own would say the same thing twice.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ACallWithoutAnIdEndsTheConversation()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// The result is correlated by that ID. Inventing one has the next request rejected, so
|
||||||
|
// there is nothing to salvage from a round like this.
|
||||||
|
//
|
||||||
|
var written = await Run(new ScriptedAdapter(
|
||||||
|
[Completed(string.Empty, [Call(string.Empty)])],
|
||||||
|
[Text(ANSWER), Completed(ANSWER)]));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(written, Does.Contain("The tool call was invalid."), "The user learns why the answer stops here.");
|
||||||
|
Assert.That(written, Does.Not.Contain(ANSWER), "And the loop does not carry on into a round the provider would refuse.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TheModelsTurnIsRecordedOncePerRoundAndBeforeItsResults()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// The provider has to know about the turn before it is sent results for it, and recording
|
||||||
|
// it twice would send the same tool call twice.
|
||||||
|
//
|
||||||
|
var adapter = new ScriptedAdapter(
|
||||||
|
[Text(PREAMBLE), Completed(PREAMBLE, [Call("call-1"), Call("call-2")])],
|
||||||
|
[Text(ANSWER), Completed(ANSWER)]);
|
||||||
|
|
||||||
|
await Run(adapter);
|
||||||
|
|
||||||
|
Assert.That(adapter.Recordings, Is.EqualTo(new[] { "turn", "result:call-1", "result:call-2" }), "One turn, then its results, in the order the model asked for them.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ACancelledStreamStopsTheLoopWhereItIs()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// What the user sees when they press stop. The provider's stream reader ends quietly on
|
||||||
|
// a cancellation rather than throwing, so the round reaches its end without completing --
|
||||||
|
// which has to leave the text alone and add nothing to it.
|
||||||
|
//
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
var adapter = new ScriptedAdapter([Text(PREAMBLE), Text(ANSWER), Completed(ANSWER)])
|
||||||
|
{
|
||||||
|
CancelAfterFirstEvent = cancellation,
|
||||||
|
};
|
||||||
|
|
||||||
|
var chunks = await Collect(adapter, cancellation.Token);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(string.Concat(chunks.Select(x => x.Content)), Is.EqualTo(PREAMBLE), "Everything written before the stop stays, and nothing after it arrives.");
|
||||||
|
Assert.That(chunks.Select(x => x.Content), Has.None.Contains(NO_ANSWER), "A stop is not a failure to answer, so it is not reported as one.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// As many rounds calling one tool each as it takes to use up the tool budget.
|
||||||
|
/// </summary>
|
||||||
|
private static List<IReadOnlyList<ToolCallingStreamEvent>> ExhaustTheToolBudget() => Enumerable
|
||||||
|
.Range(0, ToolSelectionRules.MAX_TOOL_CALLS)
|
||||||
|
.Select(IReadOnlyList<ToolCallingStreamEvent> (round) => [Completed(string.Empty, [Call($"call-{round}")])])
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private static ToolCallingStreamEvent Text(string text) => ToolCallingStreamEvent.TextDelta(text);
|
||||||
|
|
||||||
|
private static ToolCallingStreamEvent Completed(string text, IReadOnlyList<ToolCallingRequestedCall>? calls = null, IReadOnlyList<ISource>? sources = null)
|
||||||
|
=> ToolCallingStreamEvent.RoundCompleted(new ToolCallingRound(text, calls ?? [], sources ?? []));
|
||||||
|
|
||||||
|
private static ToolCallingRequestedCall Call(string callId) => new(callId, "some_tool", "{}", true);
|
||||||
|
|
||||||
|
private static async Task<string> Run(ScriptedAdapter adapter) => string.Concat((await Collect(adapter)).Select(chunk => chunk.Content));
|
||||||
|
|
||||||
|
private static async Task<List<ContentStreamChunk>> Collect(ScriptedAdapter adapter, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var loop = new ToolCallingLoop(NullLogger<ToolCallingLoop>.Instance);
|
||||||
|
var chunks = new List<ContentStreamChunk>();
|
||||||
|
await foreach (var chunk in loop.RunAsync(adapter, CreateContext(), token))
|
||||||
|
chunks.Add(chunk);
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A context which needs nothing of the application around it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Without an assistant message, every UI call of the context returns right away, which is
|
||||||
|
/// what keeps the service provider out of these tests. The tool executor gets no settings
|
||||||
|
/// service for the same reason: with no runnable tools, every call ends as blocked long
|
||||||
|
/// before any setting is read.
|
||||||
|
/// </remarks>
|
||||||
|
private static ToolCallingLoopContext CreateContext() => new()
|
||||||
|
{
|
||||||
|
ChatThread = new(),
|
||||||
|
RunnableTools = [],
|
||||||
|
ToolExecutor = new(null!, NullLogger<ToolExecutor>.Instance),
|
||||||
|
Provider = new NoProvider(),
|
||||||
|
CurrentAssistantContent = null,
|
||||||
|
ProviderInstanceName = "Test provider",
|
||||||
|
ProviderType = LLMProviders.NONE,
|
||||||
|
ModelId = "test-model",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static int Occurrences(string text, string part)
|
||||||
|
{
|
||||||
|
var count = 0;
|
||||||
|
for (var index = text.IndexOf(part, StringComparison.Ordinal); index >= 0; index = text.IndexOf(part, index + part.Length, StringComparison.Ordinal))
|
||||||
|
count++;
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An adapter which plays back a script of events, one list per round.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ScriptedAdapter(params IReadOnlyList<ToolCallingStreamEvent>[] rounds) : IToolCallingProviderAdapter
|
||||||
|
{
|
||||||
|
private readonly Queue<IReadOnlyList<ToolCallingStreamEvent>> remainingRounds = new(rounds);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, the run is cancelled right after the first event of the first round, the way
|
||||||
|
/// a user pressing stop cancels one.
|
||||||
|
/// </summary>
|
||||||
|
public CancellationTokenSource? CancelAfterFirstEvent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the loop recorded, in the order it did.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Recordings { get; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<string> RecordedRequestTexts => [];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<ToolCallingStreamEvent> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, [EnumeratorCancellation] CancellationToken token = default)
|
||||||
|
{
|
||||||
|
await Task.Yield();
|
||||||
|
if (this.remainingRounds.Count is 0)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
foreach (var streamEvent in this.remainingRounds.Dequeue())
|
||||||
|
{
|
||||||
|
//
|
||||||
|
// Ending rather than throwing, which is what the shared stream reader does when a
|
||||||
|
// cancellation reaches it: it stops reading lines and lets the round end without
|
||||||
|
// its completed event.
|
||||||
|
//
|
||||||
|
if (token.IsCancellationRequested)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
yield return streamEvent;
|
||||||
|
this.CancelAfterFirstEvent?.Cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void RecordAssistantTurn() => this.Recordings.Add("turn");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void RecordToolResult(string callId, string content, bool isError = false) => this.Recordings.Add($"result:{callId}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user