Stream the answers again whenever tools are in play (#986)
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:
Thorsten Sommer authored and GitHub committed 2026-09-20 10:44:33 +02:00
1 parent 459165f1be
commit c95cc5bacc
37 files changed
+2301 -329

No files matched your search

@@ -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; } = [];
/// <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>
/// The tool calls the model asked for.
/// </summary>
@@ -25,6 +34,7 @@ public sealed record AnthropicResponse
Id = ReadString(x, "id"),
Name = ReadString(x, "name"),
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))
.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.Harness;
@@ -15,7 +17,7 @@ namespace AIStudio.Provider.Anthropic;
/// </remarks>
public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageBase> baseMessages, string systemPrompt, int maxTokens,
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<AnthropicToolResultContent> pendingToolResults = [];
@@ -27,7 +29,7 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
public IReadOnlyList<string> RecordedRequestTexts => this.recordedRequestTexts;
/// <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:
@@ -39,7 +41,7 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
this.pendingToolResults.Clear();
}
var response = await executeRequestAsync(new ChatRequest
var request = new ChatRequest
{
Model = chatModel.Id,
Messages = [..baseMessages, ..this.internalMessages],
@@ -48,16 +50,29 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
: $"{systemPrompt}{Environment.NewLine}{Environment.NewLine}{finalResponseInstruction}",
MaxTokens = maxTokens,
Stream = false,
Stream = true,
Tools = includeTools && this.tools.Count > 0 ? this.tools : null,
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)
return null;
yield break;
this.lastResponse = response;
return new ToolCallingRound(
yield return ToolCallingStreamEvent.RoundCompleted(new ToolCallingRound(
response.GetTextOutput(),
response.GetToolUses()
.Select(toolUse => new ToolCallingRequestedCall(
@@ -66,7 +81,7 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
toolUse.Arguments,
ToolExecutor.IsValidArgumentsJson(toolUse.Arguments)))
.ToList(),
[]);
[]));
}
/// <inheritdoc />
@@ -10,8 +10,19 @@ public sealed record AnthropicToolUse
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>
/// The arguments as JSON text, which is what the tool executor works with.
/// </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
// through the harness instead of being streamed straight away: tool rounds are not
// streamed, only the final answer is.
// through the harness instead of going straight to the streaming path below. It streams
// there as well, round by round -- what the harness adds is the tools in between.
//
var toolRegistry = Program.SERVICE_PROVIDER.GetService<ToolRegistry>();
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)
{
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 loopContext = new ToolCallingLoopContext
@@ -151,30 +151,25 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n
}
/// <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>
/// <remarks>
/// Tool rounds are not streamed: the whole answer has to be there before its tool calls can
/// be executed. Only the final answer reaches the user through the streaming path.
/// 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 rounds used to go
/// without; reading the events is the adapter's business.
/// </remarks>
/// <returns>The answer, or null when the request failed and the user was already told.</returns>
private async Task<AnthropicResponse?> ExecuteMessagesRequest(ChatRequest requestDto, RequestedSecret requestedSecret, CancellationToken token)
private IAsyncEnumerable<ServerSentEvent> StreamMessagesRequest(ChatRequest requestDto, RequestedSecret requestedSecret, CancellationToken token)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "messages");
request.Headers.Add("x-api-key", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION));
request.Headers.Add("anthropic-version", "2023-06-01");
request.Content = new StringContent(JsonSerializer.Serialize(requestDto, JSON_SERIALIZER_OPTIONS), Encoding.UTF8, "application/json");
using var response = await this.HttpClient.SendAsync(request, token);
if (!response.IsSuccessStatusCode)
async Task<HttpRequestMessage> RequestBuilder()
{
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;
var request = new HttpRequestMessage(HttpMethod.Post, "messages");
request.Headers.Add("x-api-key", await requestedSecret.Secret.Decrypt(Program.ENCRYPTION));
request.Headers.Add("anthropic-version", "2023-06-01");
request.Content = new StringContent(JsonSerializer.Serialize(requestDto, JSON_SERIALIZER_OPTIONS), Encoding.UTF8, "application/json");
return request;
}
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
+136 -222
View File
@@ -3,12 +3,10 @@ using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using AIStudio.Chat;
using AIStudio.Models;
using AIStudio.Models.Live;
using AIStudio.Provider.Anthropic;
using AIStudio.Provider.OpenAI;
using AIStudio.Provider.SelfHosted;
using AIStudio.Settings;
@@ -39,20 +37,7 @@ public abstract class BaseProvider : IProvider, ISecretId
/// </summary>
private readonly ILogger logger;
protected static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower),
new AnnotationConverter(),
new MessageBaseConverter(),
new SubContentConverter(),
new SubContentImageSourceConverter(),
new SubContentImageUrlConverter(),
},
AllowTrailingCommas = false
};
protected static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = ProviderJsonOptions.OPTIONS;
/// <summary>
/// Constructor for the base provider.
@@ -840,19 +825,20 @@ public abstract class BaseProvider : IProvider, ISecretId
}
/// <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>
/// <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="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
/// <returns>The events of the stream, in the order they arrived.</returns>
protected async IAsyncEnumerable<ServerSentEvent> ReadServerSentEventsAsync(string providerName, string operationName, Func<Task<HttpRequestMessage>> requestBuilder, [EnumeratorCancellation] CancellationToken token = default)
{
// Check if annotations are supported:
var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine);
StreamReader? streamReader = null;
using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token);
var timeoutToken = timeoutTokenSource.Token;
@@ -862,7 +848,7 @@ public abstract class BaseProvider : IProvider, ISecretId
var responseData = await this.SendRequest(requestBuilder, token, timeoutToken);
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;
}
@@ -880,108 +866,139 @@ public abstract class BaseProvider : IProvider, ISecretId
{
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))
{
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
{
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)
yield break;
//
// Read the stream, line by line:
//
while (true)
try
{
try
//
// Read the stream, line by line:
//
while (true)
{
if(streamReader.EndOfStream)
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(e, "Failed to read the end-of-stream state from {ProviderName} '{ProviderInstanceName}': {ErrorMessage}", providerName, this.InstanceName, e.Message);
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 chat completion for {providerName} '{this.InstanceName}'.");
streamReader.Close();
yield break;
}
//
// Read the next line:
//
string? line;
try
{
line = await streamReader.ReadLineAsync(timeoutToken);
}
catch (Exception e)
{
// Check if the token is canceled:
if (token.IsCancellationRequested)
{
this.logger.LogWarning("The user canceled the chat completion 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 chat 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}");
this.logger.LogWarning("The user canceled the {OperationName} for {ProviderName} '{ProviderInstanceName}'.", operationName, providerName, this.InstanceName);
yield break;
}
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 {OperationName} stream for {ProviderName} '{ProviderInstanceName}' while reading the next chunk.", operationName, 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 {OperationName} stream from {ProviderName} '{ProviderInstanceName}'.", operationName, 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(e, "Failed to read the stream from {ProviderName} '{ProviderInstanceName}': {ErrorMessage}", 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;
//
// Only data lines carry a payload. Every other line goes out as it is, because
// 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();
}
}
if (line is null)
break;
// Skip empty lines:
if (string.IsNullOrWhiteSpace(line))
continue;
if (this.TryCreateProviderRequestExceptionFromStreamLine(providerName, line, out var providerRequestException))
throw providerRequestException;
// Skip lines that do not start with "data:". According
// to the specification, we only want to read the data lines:
if (!TryGetServerSentEventData(line, out var jsonData))
/// <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;
// Check if the line is the end of the stream:
if (jsonData is "[DONE]")
if (serverSentEvent.Data is "[DONE]")
yield break;
//
// Process annotation lines:
//
if (annotationSupported && line.Contains("""
"annotations":[
""", StringComparison.InvariantCulture))
if (annotationSupported && serverSentEvent.Line.Contains("""
"annotations":[
""", StringComparison.InvariantCulture))
{
TAnnotation? providerResponse;
try
{
// 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)
continue;
@@ -1009,7 +1026,7 @@ public abstract class BaseProvider : IProvider, ISecretId
try
{
// 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)
continue;
@@ -1028,8 +1045,6 @@ public abstract class BaseProvider : IProvider, ISecretId
yield return providerResponse.GetContent();
}
}
streamReader.Dispose();
}
/// <summary>
@@ -1046,132 +1061,29 @@ public abstract class BaseProvider : IProvider, ISecretId
// Check if annotations are supported:
var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine);
StreamReader? streamReader = null;
using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token);
var timeoutToken = timeoutTokenSource.Token;
try
await foreach (var serverSentEvent in this.ReadServerSentEventsAsync(providerName, "responses call", requestBuilder, token))
{
// Send the request using exponential backoff:
var responseData = await this.SendRequest(requestBuilder, token, timeoutToken);
if(responseData.IsFailedAfterAllRetries)
{
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;
//
// Read the stream, line by line:
//
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))
// Check if the line is the end of the stream. This one is read off the raw line
// rather than off a payload, because it has none:
if (serverSentEvent.Line.StartsWith("event: response.completed", StringComparison.InvariantCulture))
yield break;
if (!TryGetServerSentEventData(line, out var jsonData))
// Skip lines without a payload:
if (serverSentEvent.Data.Length is 0)
continue;
//
// Find delta lines:
//
if (jsonData.StartsWith("""
{"type":"response.output_text.delta"
""", StringComparison.InvariantCulture))
if (serverSentEvent.Data.StartsWith("""
{"type":"response.output_text.delta"
""", StringComparison.InvariantCulture))
{
TDelta? providerResponse;
try
{
// 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)
continue;
@@ -1193,7 +1105,7 @@ public abstract class BaseProvider : IProvider, ISecretId
//
// Find annotation added lines:
//
else if (annotationSupported && jsonData.StartsWith(
else if (annotationSupported && serverSentEvent.Data.StartsWith(
"""
{"type":"response.output_text.annotation.added"
""", StringComparison.InvariantCulture))
@@ -1202,7 +1114,7 @@ public abstract class BaseProvider : IProvider, ISecretId
try
{
// 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)
continue;
@@ -1221,8 +1133,6 @@ public abstract class BaseProvider : IProvider, ISecretId
yield return new(string.Empty, providerResponse.GetSources());
}
}
streamReader.Dispose();
}
/// <summary>
@@ -1293,8 +1203,9 @@ public abstract class BaseProvider : IProvider, ISecretId
{
var adapter = new ChatCompletionToolCallingAdapter<TRequest>(requestFactory, systemPrompt, apiParameters,
runnableTools.Select(x => ProviderToolAdapters.ToChatCompletionTool(x.Definition)).ToList(), runnableTools,
(requestDto, requestToken) => this.ExecuteChatCompletionRequest(requestDto, requestPath, requestedSecret, headersAction, requestToken),
this.InstanceName, this.logger);
(requestDto, requestToken) => this.StreamChatCompletionRequest(requestDto, providerName, requestPath, requestedSecret, headersAction, requestToken),
ChatCompletionSourceReader.Read<TDelta, TAnnotation>,
this.logger);
var loop = Program.SERVICE_PROVIDER.GetRequiredService<IToolCallingLoop>();
var loopContext = new ToolCallingLoopContext
@@ -1365,16 +1276,17 @@ public abstract class BaseProvider : IProvider, ISecretId
CapabilityOverrides = this.CapabilityOverrides,
};
private async Task<ChatCompletionResponse?> ExecuteChatCompletionRequest(ChatCompletionAPIRequest requestDto, string requestPath, RequestedSecret requestedSecret,
Action<HttpRequestHeaders>? headersAction, CancellationToken token)
/// <summary>
/// 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()
{
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");
return request;
}
return this.ReadServerSentEventsAsync(providerName, "chat completion", RequestBuilder, token);
}
/// <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 AIStudio.Tools.ToolCallingSystem;
@@ -17,8 +18,9 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
TextMessage systemPrompt, IDictionary<string, object> apiParameters,
IList<object> providerTools,
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
Func<ChatCompletionAPIRequest, CancellationToken, Task<ChatCompletionResponse?>> executeRequestAsync,
string providerInstanceName, ILogger logger)
Func<ChatCompletionAPIRequest, CancellationToken, IAsyncEnumerable<ServerSentEvent>> streamRequestAsync,
Func<ServerSentEvent, IList<ISource>> readSources,
ILogger logger)
: IToolCallingProviderAdapter where TRequest : ChatCompletionAPIRequest
{
private readonly List<IMessageBase> internalMessages = [];
@@ -30,7 +32,7 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
public IReadOnlyList<string> RecordedRequestTexts => this.recordedRequestTexts;
/// <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
? systemPrompt : systemPrompt with
@@ -42,7 +44,7 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
var requestDto = requestDtoBase with
{
Messages = [..requestDtoBase.Messages, ..this.internalMessages],
Stream = false,
Stream = true,
//
// 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,
};
var response = await executeRequestAsync(requestDto, token);
if (response is null)
return null;
// The response comes from a provider, so its shape is a promise rather than a guarantee:
// a JSON null for the choices field overwrites the initialized property with null.
// ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
var responseChoice = response.Choices?.FirstOrDefault();
if (responseChoice?.Message is null)
//
// The text goes out while it is being written; the tool calls are put back together
// behind it, fragment by fragment.
//
var accumulator = new ChatCompletionToolCallAccumulator(readSources);
await foreach (var serverSentEvent in streamRequestAsync(requestDto, token))
{
logger.LogError(
"The tool calling response did not contain a usable choice. ProviderInstanceName={ProviderInstanceName}, ChoiceCount={ChoiceCount}",
providerInstanceName,
response.Choices?.Count ?? 0);
throw ToolCallingMessages.InvalidToolCallingResponse(providerInstanceName);
var part = accumulator.Process(serverSentEvent);
if (part.HasContent)
yield return ToolCallingStreamEvent.TextDelta(new ContentStreamChunk(part.TextDelta, part.Sources));
}
this.lastResponseMessage = responseChoice.Message;
var preparedCalls = this.PrepareToolCalls(responseChoice.Message.ToolCalls ?? []);
var message = accumulator.Build();
if (message is null)
yield break;
this.lastResponseMessage = message;
var preparedCalls = this.PrepareToolCalls(message.ToolCalls ?? []);
this.lastToolCalls = preparedCalls.Select(x => x.ToolCall).ToList();
return new ToolCallingRound(
responseChoice.Message.Content ?? string.Empty,
yield return ToolCallingStreamEvent.RoundCompleted(new ToolCallingRound(
message.Content ?? string.Empty,
preparedCalls
.Select(x => new ToolCallingRequestedCall(x.ToolCall.Id!, x.ToolCall.Function!.Name!, x.ToolCall.Function!.Arguments!, x.IsValid))
.ToList(),
[]);
[]));
}
/// <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,
providerTools,
runnableTools,
(requestDto, requestToken) => this.ExecuteResponsesRequest(requestDto, requestedSecret, requestToken));
(requestDto, requestToken) => this.StreamResponsesRequest(requestDto, requestedSecret, requestToken));
var loop = Program.SERVICE_PROVIDER.GetRequiredService<IToolCallingLoop>();
var loopContext = new ToolCallingLoopContext
@@ -316,22 +316,25 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
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");
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");
return this.ReadServerSentEventsAsync("OpenAI", "responses call", RequestBuilder, token);
using var response = await this.HttpClient.SendAsync(request, token);
if (!response.IsSuccessStatusCode)
async Task<HttpRequestMessage> RequestBuilder()
{
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;
var request = new HttpRequestMessage(HttpMethod.Post, "responses");
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");
return request;
}
return await response.Content.ReadFromJsonAsync<ResponsesResponse>(JSON_SERIALIZER_OPTIONS, token);
}
#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.Harness;
@@ -13,7 +15,7 @@ namespace AIStudio.Provider.OpenAI;
/// </remarks>
public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> baseInput, IDictionary<string, object> apiParameters, IList<object> providerTools,
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<string> recordedRequestTexts = [];
@@ -32,7 +34,7 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
private readonly IList<object> effectiveProviderTools = BuildEffectiveProviderTools(providerTools, runnableTools);
/// <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);
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);
var response = await executeRequestAsync(new ResponsesAPIRequest
var request = new ResponsesAPIRequest
{
Model = chatModel.Id,
Input = requestInput,
Stream = false,
Stream = true,
Store = false,
Tools = includeTools ? this.effectiveProviderTools : [],
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)
return null;
yield break;
this.lastResponse = response;
return new ToolCallingRound(
yield return ToolCallingStreamEvent.RoundCompleted(new ToolCallingRound(
response.GetTextOutput(),
response.GetFunctionCalls()
.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)))
.ToList(),
response.GetSources());
response.GetSources()));
}
/// <inheritdoc />
@@ -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
};
}
@@ -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
{
/// <summary>
/// Executes one non-streamed round and returns what the model answered.
/// Executes one round and streams what the model answers.
/// </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">
/// 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.
@@ -23,10 +29,12 @@ public interface IToolCallingProviderAdapter
/// <param name="includeTools">Whether the tools may be offered in this round.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>
/// The round's outcome, or null when the request failed. Null ends the loop without an error
/// message because the adapter has already told the user what went wrong.
/// The events of this round: any number of TEXT_DELTA events, closed by one ROUND_COMPLETED
/// 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>
public Task<ToolCallingRound?> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default);
public IAsyncEnumerable<ToolCallingStreamEvent> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default);
/// <summary>
/// Records the model's turn from the round just executed, so that the next round sees it.
@@ -18,7 +18,17 @@ 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_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 />
public async IAsyncEnumerable<ContentStreamChunk> RunAsync(
IToolCallingProviderAdapter adapter,
@@ -28,6 +38,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
var toolCallCount = 0;
var toolResultCharacterCount = 0L;
var toolSources = new List<Source>();
var hasStreamedTextBefore = false;
while (true)
{
@@ -38,13 +49,52 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
var finalResponseInstruction = ToolSelectionRules.GetToolCallsUnavailableInstruction(toolCallCount, toolResultCharacterCount);
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)
{
await context.ResetToolRuntimeStatusAsync();
yield break;
}
var roundAnswered = roundStreamedText || !string.IsNullOrWhiteSpace(round.TextOutput);
toolSources.MergeSources(round.Sources);
//
@@ -65,8 +115,14 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
if (finalResponseRequired)
{
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(
string.IsNullOrWhiteSpace(round.TextOutput) ? NO_ANSWER_AFTER_LIMIT : round.TextOutput,
roundAnswered ? string.Empty : NO_ANSWER_AFTER_LIMIT,
[..toolSources]);
yield break;
@@ -75,9 +131,9 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
if (round.Calls.Count is 0)
{
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;
}
@@ -1,10 +1,14 @@
namespace AIStudio.Tools.ToolCallingSystem.Harness;
/// <summary>
/// The outcome of one non-streamed round of a tool calling conversation, in a shape that no
/// longer depends on the provider API it came from.
/// The outcome of one round of a tool calling conversation, in a shape that no longer depends on
/// the provider API it came from.
/// </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="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);
@@ -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)
- 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 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.