mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-17 23:18:00 +00:00
Carry provider sources through the Chat Completions tool rounds
This commit is contained in:
5 files changed
+89
-9
No files matched your search
@@ -1204,6 +1204,7 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
var adapter = new ChatCompletionToolCallingAdapter<TRequest>(requestFactory, systemPrompt, apiParameters,
|
var adapter = new ChatCompletionToolCallingAdapter<TRequest>(requestFactory, systemPrompt, apiParameters,
|
||||||
runnableTools.Select(x => ProviderToolAdapters.ToChatCompletionTool(x.Definition)).ToList(), runnableTools,
|
runnableTools.Select(x => ProviderToolAdapters.ToChatCompletionTool(x.Definition)).ToList(), runnableTools,
|
||||||
(requestDto, requestToken) => this.StreamChatCompletionRequest(requestDto, providerName, requestPath, requestedSecret, headersAction, requestToken),
|
(requestDto, requestToken) => this.StreamChatCompletionRequest(requestDto, providerName, requestPath, requestedSecret, headersAction, requestToken),
|
||||||
|
ChatCompletionSourceReader.Read<TDelta, TAnnotation>,
|
||||||
this.logger);
|
this.logger);
|
||||||
|
|
||||||
var loop = Program.SERVICE_PROVIDER.GetRequiredService<IToolCallingLoop>();
|
var loop = Program.SERVICE_PROVIDER.GetRequiredService<IToolCallingLoop>();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,15 +4,16 @@ namespace AIStudio.Provider.OpenAI;
|
|||||||
/// What one line of a streamed Chat Completions answer has to show to the user.
|
/// What one line of a streamed Chat Completions answer has to show to the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="TextDelta">The text this line carried, empty when it carried none.</param>
|
/// <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>
|
/// <summary>
|
||||||
/// The part of a line which says nothing to the user, such as a fragment of a tool call.
|
/// The part of a line which says nothing to the user, such as a fragment of a tool call.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ChatCompletionStreamPart Nothing => new(string.Empty);
|
public static ChatCompletionStreamPart Nothing => new(string.Empty, []);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether this part has anything to show at all.
|
/// Whether this part has anything to show at all.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool HasContent => this.TextDelta.Length > 0;
|
public bool HasContent => this.TextDelta.Length > 0 || this.Sources.Count > 0;
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,11 @@ namespace AIStudio.Provider.OpenAI;
|
|||||||
/// No HTTP, no dependency injection, no provider: what happens here are decisions about bytes,
|
/// No HTTP, no dependency injection, no provider: what happens here are decisions about bytes,
|
||||||
/// and those are the decisions worth having a test for.
|
/// and those are the decisions worth having a test for.
|
||||||
/// </remarks>
|
/// </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]";
|
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
|
// 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.
|
// 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;
|
var delta = line?.Choices?.FirstOrDefault()?.Delta;
|
||||||
if (delta is null)
|
if (delta is null)
|
||||||
return ChatCompletionStreamPart.Nothing;
|
return WithSources(string.Empty, sources);
|
||||||
|
|
||||||
this.hasReadAnything = true;
|
this.hasReadAnything = true;
|
||||||
|
|
||||||
@@ -68,10 +79,10 @@ public sealed class ChatCompletionToolCallAccumulator
|
|||||||
|
|
||||||
var textDelta = delta.Content;
|
var textDelta = delta.Content;
|
||||||
if (textDelta.Length is 0)
|
if (textDelta.Length is 0)
|
||||||
return ChatCompletionStreamPart.Nothing;
|
return WithSources(string.Empty, sources);
|
||||||
|
|
||||||
this.text.Append(textDelta);
|
this.text.Append(textDelta);
|
||||||
return new ChatCompletionStreamPart(textDelta);
|
return new ChatCompletionStreamPart(textDelta, sources);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -169,6 +180,12 @@ public sealed class ChatCompletionToolCallAccumulator
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static string? Coalesce(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
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>
|
/// <summary>
|
||||||
/// One tool call while its fragments are still arriving.
|
/// One tool call while its fragments are still arriving.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
|
|||||||
IList<object> providerTools,
|
IList<object> providerTools,
|
||||||
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
|
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools,
|
||||||
Func<ChatCompletionAPIRequest, CancellationToken, IAsyncEnumerable<ServerSentEvent>> streamRequestAsync,
|
Func<ChatCompletionAPIRequest, CancellationToken, IAsyncEnumerable<ServerSentEvent>> streamRequestAsync,
|
||||||
|
Func<ServerSentEvent, IList<ISource>> readSources,
|
||||||
ILogger logger)
|
ILogger logger)
|
||||||
: IToolCallingProviderAdapter where TRequest : ChatCompletionAPIRequest
|
: 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
|
// The text goes out while it is being written; the tool calls are put back together
|
||||||
// behind it, fragment by fragment.
|
// behind it, fragment by fragment.
|
||||||
//
|
//
|
||||||
var accumulator = new ChatCompletionToolCallAccumulator();
|
var accumulator = new ChatCompletionToolCallAccumulator(readSources);
|
||||||
await foreach (var serverSentEvent in streamRequestAsync(requestDto, token))
|
await foreach (var serverSentEvent in streamRequestAsync(requestDto, token))
|
||||||
{
|
{
|
||||||
var part = accumulator.Process(serverSentEvent);
|
var part = accumulator.Process(serverSentEvent);
|
||||||
if (part.HasContent)
|
if (part.HasContent)
|
||||||
yield return ToolCallingStreamEvent.TextDelta(part.TextDelta);
|
yield return ToolCallingStreamEvent.TextDelta(new ContentStreamChunk(part.TextDelta, part.Sources));
|
||||||
}
|
}
|
||||||
|
|
||||||
var message = accumulator.Build();
|
var message = accumulator.Build();
|
||||||
|
|||||||
Reference in new issue
Block a user