Carry provider sources through the Chat Completions tool rounds

This commit is contained in:
Thorsten Sommer 2026-09-20 10:22:10 +02:00
parent d4846d253f
commit 2c60715031
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
5 changed files with 89 additions and 9 deletions

View File

@ -1204,6 +1204,7 @@ 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.StreamChatCompletionRequest(requestDto, providerName, requestPath, requestedSecret, headersAction, requestToken),
ChatCompletionSourceReader.Read<TDelta, TAnnotation>,
this.logger);
var loop = Program.SERVICE_PROVIDER.GetRequiredService<IToolCallingLoop>();

View File

@ -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;
}
}
}

View File

@ -4,15 +4,16 @@ namespace AIStudio.Provider.OpenAI;
/// 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>
public readonly record struct ChatCompletionStreamPart(string TextDelta)
/// <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);
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;
public bool HasContent => this.TextDelta.Length > 0 || this.Sources.Count > 0;
}

View File

@ -14,7 +14,11 @@ namespace AIStudio.Provider.OpenAI;
/// 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 ChatCompletionToolCallAccumulator
/// <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]";
@ -49,9 +53,16 @@ public sealed class ChatCompletionToolCallAccumulator
// 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 ChatCompletionStreamPart.Nothing;
return WithSources(string.Empty, sources);
this.hasReadAnything = true;
@ -68,10 +79,10 @@ public sealed class ChatCompletionToolCallAccumulator
var textDelta = delta.Content;
if (textDelta.Length is 0)
return ChatCompletionStreamPart.Nothing;
return WithSources(string.Empty, sources);
this.text.Append(textDelta);
return new ChatCompletionStreamPart(textDelta);
return new ChatCompletionStreamPart(textDelta, sources);
}
/// <summary>
@ -169,6 +180,12 @@ public sealed class ChatCompletionToolCallAccumulator
}
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.

View File

@ -19,6 +19,7 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
IList<object> providerTools,
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
Func<ChatCompletionAPIRequest, CancellationToken, IAsyncEnumerable<ServerSentEvent>> streamRequestAsync,
Func<ServerSentEvent, IList<ISource>> readSources,
ILogger logger)
: IToolCallingProviderAdapter where TRequest : ChatCompletionAPIRequest
{
@ -57,12 +58,12 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
// 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();
var accumulator = new ChatCompletionToolCallAccumulator(readSources);
await foreach (var serverSentEvent in streamRequestAsync(requestDto, token))
{
var part = accumulator.Process(serverSentEvent);
if (part.HasContent)
yield return ToolCallingStreamEvent.TextDelta(part.TextDelta);
yield return ToolCallingStreamEvent.TextDelta(new ContentStreamChunk(part.TextDelta, part.Sources));
}
var message = accumulator.Build();