mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 02:33:38 +00:00
Carry provider sources through the Chat Completions tool rounds
This commit is contained in:
parent
d4846d253f
commit
2c60715031
@ -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>();
|
||||
|
||||
@ -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.
|
||||
/// </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;
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user