From 2c607150318c70aae7c58e26f22ad99970ecfc74 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 20 Sep 2026 10:22:10 +0200 Subject: [PATCH] Carry provider sources through the Chat Completions tool rounds --- .../Provider/BaseProvider.cs | 1 + .../OpenAI/ChatCompletionSourceReader.cs | 60 +++++++++++++++++++ .../OpenAI/ChatCompletionStreamPart.cs | 7 ++- .../ChatCompletionToolCallAccumulator.cs | 25 ++++++-- .../ChatCompletionToolCallingAdapter.cs | 5 +- 5 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 app/MindWork AI Studio/Provider/OpenAI/ChatCompletionSourceReader.cs diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index d8f1f19b..c35ff5ac 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -1204,6 +1204,7 @@ public abstract class BaseProvider : IProvider, ISecretId var adapter = new ChatCompletionToolCallingAdapter(requestFactory, systemPrompt, apiParameters, runnableTools.Select(x => ProviderToolAdapters.ToChatCompletionTool(x.Definition)).ToList(), runnableTools, (requestDto, requestToken) => this.StreamChatCompletionRequest(requestDto, providerName, requestPath, requestedSecret, headersAction, requestToken), + ChatCompletionSourceReader.Read, this.logger); var loop = Program.SERVICE_PROVIDER.GetRequiredService(); diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionSourceReader.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionSourceReader.cs new file mode 100644 index 00000000..e58be449 --- /dev/null +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionSourceReader.cs @@ -0,0 +1,60 @@ +using System.Text.Json; + +namespace AIStudio.Provider.OpenAI; + +/// +/// Reads the sources a provider puts into its Chat Completions stream. +/// +/// +/// 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. +/// +public static class ChatCompletionSourceReader +{ + private const string DONE = "[DONE]"; + + /// + /// Reads whatever sources one line of the stream announced. + /// + /// The event to read. + /// The provider's delta stream line type. + /// The provider's annotation stream line type. + /// The sources of this line, empty when it announced none. + public static IList Read(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(serverSentEvent.Data); + return annotationLine is not null && annotationLine.ContainsSources() ? annotationLine.GetSources() : []; + } + + var deltaLine = TryDeserialize(serverSentEvent.Data); + return deltaLine is not null && deltaLine.ContainsSources() ? deltaLine.GetSources() : []; + } + + private static T? TryDeserialize(string json) + { + try + { + return JsonSerializer.Deserialize(json, ProviderJsonOptions.OPTIONS); + } + catch (JsonException) + { + return default; + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamPart.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamPart.cs index 636e6950..6b152b93 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamPart.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionStreamPart.cs @@ -4,15 +4,16 @@ namespace AIStudio.Provider.OpenAI; /// What one line of a streamed Chat Completions answer has to show to the user. /// /// The text this line carried, empty when it carried none. -public readonly record struct ChatCompletionStreamPart(string TextDelta) +/// The sources this line announced, empty when it announced none. +public readonly record struct ChatCompletionStreamPart(string TextDelta, IList Sources) { /// /// The part of a line which says nothing to the user, such as a fragment of a tool call. /// - public static ChatCompletionStreamPart Nothing => new(string.Empty); + public static ChatCompletionStreamPart Nothing => new(string.Empty, []); /// /// Whether this part has anything to show at all. /// - public bool HasContent => this.TextDelta.Length > 0; + public bool HasContent => this.TextDelta.Length > 0 || this.Sources.Count > 0; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallAccumulator.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallAccumulator.cs index f2d65f86..6bc7c069 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallAccumulator.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallAccumulator.cs @@ -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. /// -public sealed class ChatCompletionToolCallAccumulator +/// +/// 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. +/// +public sealed class ChatCompletionToolCallAccumulator(Func>? 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); } /// @@ -169,6 +180,12 @@ public sealed class ChatCompletionToolCallAccumulator } private static string? Coalesce(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; + + /// + /// A part for a line which brought sources but no text, or nothing at all. + /// + private static ChatCompletionStreamPart WithSources(string text, IList sources) + => sources.Count is 0 ? ChatCompletionStreamPart.Nothing : new ChatCompletionStreamPart(text, sources); /// /// One tool call while its fragments are still arriving. diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs index 85acf31a..43bdfef4 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs @@ -19,6 +19,7 @@ public sealed class ChatCompletionToolCallingAdapter( IList providerTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools, Func> streamRequestAsync, + Func> readSources, ILogger logger) : IToolCallingProviderAdapter where TRequest : ChatCompletionAPIRequest { @@ -57,12 +58,12 @@ public sealed class ChatCompletionToolCallingAdapter( // 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();