mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
dac17a8f48
* Guidance * Guidance * WIP Migration Preps * Move to single file projects * Update guidance code and final adjustments to ensure all feature compatibility * Move from demos to samples * Address format * Address chat client pipeline order * Update project naming * Revisition on README * Remove unused ctor * Address feedback * Address feedback * Address merge conflict fix * Address SK versioning * Address folder naming * Address feedback
723 lines
35 KiB
C#
723 lines
35 KiB
C#
#pragma warning disable IDE0005 // Using directive is unnecessary.
|
|
#pragma warning disable IDE0073 // The file header does not match the required text
|
|
#pragma warning disable CS0436 // Type conflicts with imported type
|
|
#pragma warning disable CA1063 // Implement IDisposable Correctly
|
|
|
|
// Licensed to the .NET Foundation under one or more agreements.
|
|
// The .NET Foundation licenses this file to you under the MIT license.
|
|
|
|
using System.Reflection;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.Shared.Diagnostics;
|
|
using OpenAI;
|
|
using OpenAI.Assistants;
|
|
|
|
#pragma warning disable CA1031 // Do not catch general exception types
|
|
#pragma warning disable SA1005 // Single line comments should begin with single space
|
|
#pragma warning disable SA1204 // Static elements should appear before instance elements
|
|
#pragma warning disable S125 // Sections of code should not be commented out
|
|
#pragma warning disable S907 // "goto" statement should not be used
|
|
#pragma warning disable S1067 // Expressions should not be too complex
|
|
#pragma warning disable S1751 // Loops with at most one iteration should be refactored
|
|
#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields
|
|
#pragma warning disable S4456 // Parameter validation in yielding methods should be wrapped
|
|
#pragma warning disable S4457 // Parameter validation in "async"/"await" methods should be wrapped
|
|
|
|
namespace Microsoft.Extensions.AI;
|
|
|
|
/// <summary>Represents an <see cref="IChatClient"/> for an OpenAI <see cref="AssistantClient"/>.</summary>
|
|
internal sealed class NewOpenAIAssistantsChatClient : IChatClient
|
|
{
|
|
/// <summary>The underlying <see cref="AssistantClient" />.</summary>
|
|
private readonly AssistantClient _client;
|
|
|
|
/// <summary>Metadata for the client.</summary>
|
|
private readonly ChatClientMetadata _metadata;
|
|
|
|
/// <summary>The ID of the agent to use.</summary>
|
|
private readonly string _assistantId;
|
|
|
|
/// <summary>The thread ID to use if none is supplied in <see cref="ChatOptions.ConversationId"/>.</summary>
|
|
private readonly string? _defaultThreadId;
|
|
|
|
/// <summary>List of tools associated with the assistant.</summary>
|
|
private IReadOnlyList<ToolDefinition>? _assistantTools;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="OpenAIAssistantsChatClient"/> class for the specified <see cref="AssistantClient"/>.</summary>
|
|
public NewOpenAIAssistantsChatClient(AssistantClient assistantClient, string assistantId, string? defaultThreadId)
|
|
{
|
|
_client = Throw.IfNull(assistantClient);
|
|
_assistantId = Throw.IfNullOrWhitespace(assistantId);
|
|
|
|
_defaultThreadId = defaultThreadId;
|
|
|
|
// https://github.com/openai/openai-dotnet/issues/215
|
|
// The endpoint isn't currently exposed, so use reflection to get at it, temporarily. Once packages
|
|
// implement the abstractions directly rather than providing adapters on top of the public APIs,
|
|
// the package can provide such implementations separate from what's exposed in the public API.
|
|
Uri providerUrl = typeof(AssistantClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
|
|
?.GetValue(assistantClient) as Uri ?? OpenAIClientExtensions2.DefaultOpenAIEndpoint;
|
|
|
|
_metadata = new("openai", providerUrl);
|
|
}
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="OpenAIAssistantsChatClient"/> class for the specified <see cref="AssistantClient"/>.</summary>
|
|
public NewOpenAIAssistantsChatClient(AssistantClient assistantClient, Assistant assistant, string? defaultThreadId)
|
|
: this(assistantClient, Throw.IfNull(assistant).Id, defaultThreadId)
|
|
{
|
|
_assistantTools = assistant.Tools;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public object? GetService(Type serviceType, object? serviceKey = null) =>
|
|
serviceType is null ? throw new ArgumentNullException(nameof(serviceType)) :
|
|
serviceKey is not null ? null :
|
|
serviceType == typeof(ChatClientMetadata) ? _metadata :
|
|
serviceType == typeof(AssistantClient) ? _client :
|
|
serviceType.IsInstanceOfType(this) ? this :
|
|
null;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ChatResponse> GetResponseAsync(
|
|
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
|
{
|
|
// Changing the original implementation to provide a RawRepresentation as a list of RawRepresentations of the updates.
|
|
// This wouldn't be needed if the API Change Proposal below is accepted:
|
|
// https://github.com/dotnet/extensions/issues/6746
|
|
var updates = await GetStreamingResponseAsync(messages, options, cancellationToken).ToListAsync(cancellationToken).ConfigureAwait(false);
|
|
var response = updates.ToChatResponse();
|
|
|
|
// Expose all the raw representations of the updates.
|
|
response.RawRepresentation = updates.Select(u => u.RawRepresentation).ToArray();
|
|
return response;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
|
IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
|
{
|
|
_ = Throw.IfNull(messages);
|
|
|
|
// Extract necessary state from messages and options.
|
|
(RunCreationOptions runOptions, ToolResources? toolResources, List<FunctionResultContent>? toolResults) = await CreateRunOptionsAsync(messages, options, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Get the thread ID.
|
|
string? threadId = options?.ConversationId ?? _defaultThreadId;
|
|
if (threadId is null && toolResults is not null)
|
|
{
|
|
Throw.ArgumentException(nameof(messages), "No thread ID was provided, but chat messages includes tool results.");
|
|
}
|
|
|
|
// Get any active run ID for this thread. This is necessary in case a thread has been left with an
|
|
// active run, in which all attempts other than submitting tools will fail. We thus need to cancel
|
|
// any active run on the thread.
|
|
ThreadRun? threadRun = null;
|
|
if (threadId is not null)
|
|
{
|
|
await foreach (var run in _client.GetRunsAsync(
|
|
threadId,
|
|
new RunCollectionOptions { Order = RunCollectionOrder.Descending, PageSizeLimit = 1 },
|
|
cancellationToken: cancellationToken).ConfigureAwait(false))
|
|
{
|
|
if (run.Status != RunStatus.Completed && run.Status != RunStatus.Cancelled && run.Status != RunStatus.Failed && run.Status != RunStatus.Expired)
|
|
{
|
|
threadRun = run;
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Submit the request.
|
|
IAsyncEnumerable<StreamingUpdate> updates;
|
|
if (threadRun is not null &&
|
|
ConvertFunctionResultsToToolOutput(toolResults, out List<ToolOutput>? toolOutputs) is { } toolRunId &&
|
|
toolRunId == threadRun.Id)
|
|
{
|
|
// There's an active run and we have tool results to submit, so submit the results and continue streaming.
|
|
// This is going to ignore any additional messages in the run options, as we are only submitting tool outputs,
|
|
// but there doesn't appear to be a way to submit additional messages, and having such additional messages is rare.
|
|
updates = _client.SubmitToolOutputsToRunStreamingAsync(threadRun.ThreadId, threadRun.Id, toolOutputs, cancellationToken);
|
|
}
|
|
else
|
|
{
|
|
if (threadId is null)
|
|
{
|
|
// No thread ID was provided, so create a new thread.
|
|
ThreadCreationOptions threadCreationOptions = new()
|
|
{
|
|
ToolResources = toolResources,
|
|
};
|
|
|
|
foreach (var message in runOptions.AdditionalMessages)
|
|
{
|
|
threadCreationOptions.InitialMessages.Add(message);
|
|
}
|
|
|
|
runOptions.AdditionalMessages.Clear();
|
|
|
|
var thread = await _client.CreateThreadAsync(threadCreationOptions, cancellationToken).ConfigureAwait(false);
|
|
threadId = thread.Value.Id;
|
|
}
|
|
else if (threadRun is not null)
|
|
{
|
|
// There was an active run; we need to cancel it before starting a new run.
|
|
_ = await _client.CancelRunAsync(threadId, threadRun.Id, cancellationToken).ConfigureAwait(false);
|
|
threadRun = null;
|
|
}
|
|
|
|
// Now create a new run and stream the results.
|
|
updates = _client.CreateRunStreamingAsync(
|
|
threadId: threadId,
|
|
_assistantId,
|
|
runOptions,
|
|
cancellationToken);
|
|
}
|
|
|
|
// Process each update.
|
|
string? responseId = null;
|
|
await foreach (var update in updates.ConfigureAwait(false))
|
|
{
|
|
switch (update)
|
|
{
|
|
case ThreadUpdate tu:
|
|
threadId ??= tu.Value.Id;
|
|
goto default;
|
|
|
|
case RunUpdate ru:
|
|
threadId ??= ru.Value.ThreadId;
|
|
responseId ??= ru.Value.Id;
|
|
|
|
ChatResponseUpdate ruUpdate = new()
|
|
{
|
|
AuthorName = _assistantId,
|
|
ConversationId = threadId,
|
|
CreatedAt = ru.Value.CreatedAt,
|
|
MessageId = responseId,
|
|
ModelId = ru.Value.Model,
|
|
RawRepresentation = ru,
|
|
ResponseId = responseId,
|
|
Role = ChatRole.Assistant,
|
|
};
|
|
|
|
if (ru.Value.Usage is { } usage)
|
|
{
|
|
ruUpdate.Contents.Add(new UsageContent(new()
|
|
{
|
|
InputTokenCount = usage.InputTokenCount,
|
|
OutputTokenCount = usage.OutputTokenCount,
|
|
TotalTokenCount = usage.TotalTokenCount,
|
|
}));
|
|
}
|
|
|
|
if (ru is RequiredActionUpdate rau && rau.ToolCallId is string toolCallId && rau.FunctionName is string functionName)
|
|
{
|
|
var fcc = OpenAIClientExtensions2.ParseCallContent(
|
|
rau.FunctionArguments,
|
|
JsonSerializer.Serialize([ru.Value.Id, toolCallId], OpenAIJsonContext.Default.StringArray),
|
|
functionName);
|
|
fcc.RawRepresentation = ru;
|
|
ruUpdate.Contents.Add(fcc);
|
|
}
|
|
|
|
yield return ruUpdate;
|
|
break;
|
|
|
|
case MessageContentUpdate mcu:
|
|
ChatResponseUpdate textUpdate = new(mcu.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant, mcu.Text)
|
|
{
|
|
AuthorName = _assistantId,
|
|
ConversationId = threadId,
|
|
MessageId = responseId,
|
|
RawRepresentation = mcu,
|
|
ResponseId = responseId,
|
|
};
|
|
|
|
// Add any annotations from the text update. The OpenAI Assistants API does not support passing these back
|
|
// into the model (MessageContent.FromXx does not support providing annotations), so they end up being one way and are dropped
|
|
// on subsequent requests.
|
|
if (mcu.TextAnnotation is { } tau)
|
|
{
|
|
string? fileId = null;
|
|
string? toolName = null;
|
|
if (!string.IsNullOrWhiteSpace(tau.InputFileId))
|
|
{
|
|
fileId = tau.InputFileId;
|
|
toolName = "file_search";
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(tau.OutputFileId))
|
|
{
|
|
fileId = tau.OutputFileId;
|
|
toolName = "code_interpreter";
|
|
}
|
|
|
|
if (fileId is not null)
|
|
{
|
|
if (textUpdate.Contents.Count == 0)
|
|
{
|
|
// In case a chunk doesn't have text content, create one with empty text to hold the annotation.
|
|
textUpdate.Contents.Add(new TextContent(string.Empty));
|
|
}
|
|
|
|
(((TextContent)textUpdate.Contents[0]).Annotations ??= []).Add(new CitationAnnotation
|
|
{
|
|
RawRepresentation = tau,
|
|
AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = tau.StartIndex, EndIndex = tau.EndIndex }],
|
|
FileId = fileId,
|
|
ToolName = toolName,
|
|
});
|
|
}
|
|
}
|
|
|
|
yield return textUpdate;
|
|
break;
|
|
|
|
default:
|
|
yield return new()
|
|
{
|
|
AuthorName = _assistantId,
|
|
ConversationId = threadId,
|
|
MessageId = responseId,
|
|
RawRepresentation = update,
|
|
ResponseId = responseId,
|
|
Role = ChatRole.Assistant,
|
|
};
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
void IDisposable.Dispose()
|
|
{
|
|
// nop
|
|
}
|
|
|
|
/// <summary>Converts an Extensions function to an OpenAI assistants function tool.</summary>
|
|
internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction, ChatOptions? options = null)
|
|
{
|
|
bool? strict =
|
|
OpenAIClientExtensions2.HasStrict(aiFunction.AdditionalProperties) ??
|
|
OpenAIClientExtensions2.HasStrict(options?.AdditionalProperties);
|
|
|
|
return new FunctionToolDefinition(aiFunction.Name)
|
|
{
|
|
Description = aiFunction.Description,
|
|
Parameters = OpenAIClientExtensions2.ToOpenAIFunctionParameters(aiFunction, strict),
|
|
StrictParameterSchemaEnabled = strict,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the <see cref="RunCreationOptions"/> to use for the request and extracts any function result contents
|
|
/// that need to be submitted as tool results.
|
|
/// </summary>
|
|
private async ValueTask<(RunCreationOptions RunOptions, ToolResources? Resources, List<FunctionResultContent>? ToolResults)> CreateRunOptionsAsync(
|
|
IEnumerable<ChatMessage> messages, ChatOptions? options, CancellationToken cancellationToken)
|
|
{
|
|
// Create the options instance to populate, either a fresh or using one the caller provides.
|
|
RunCreationOptions runOptions =
|
|
options?.RawRepresentationFactory?.Invoke(this) as RunCreationOptions ??
|
|
new();
|
|
|
|
ToolResources? resources = null;
|
|
|
|
// Populate the run options from the ChatOptions, if provided.
|
|
if (options is not null)
|
|
{
|
|
runOptions.MaxOutputTokenCount ??= options.MaxOutputTokens;
|
|
runOptions.ModelOverride ??= options.ModelId;
|
|
runOptions.NucleusSamplingFactor ??= options.TopP;
|
|
runOptions.Temperature ??= options.Temperature;
|
|
runOptions.AllowParallelToolCalls ??= options.AllowMultipleToolCalls;
|
|
|
|
if (options.Tools is { Count: > 0 } tools)
|
|
{
|
|
// If the caller has provided any tool overrides, we'll assume they don't want to use the assistant's tools.
|
|
// But if they haven't, the only way we can provide our tools is via an override, whereas we'd really like to
|
|
// just add them. To handle that, we'll get all of the assistant's tools and add them to the override list
|
|
// along with our tools.
|
|
if (runOptions.ToolsOverride.Count == 0)
|
|
{
|
|
if (_assistantTools is null)
|
|
{
|
|
var assistant = await _client.GetAssistantAsync(_assistantId, cancellationToken).ConfigureAwait(false);
|
|
_assistantTools = assistant.Value.Tools;
|
|
}
|
|
|
|
foreach (var tool in _assistantTools)
|
|
{
|
|
runOptions.ToolsOverride.Add(tool);
|
|
}
|
|
}
|
|
|
|
// The caller can provide tools in the supplied ThreadAndRunOptions. Augment it with any supplied via ChatOptions.Tools.
|
|
foreach (AITool tool in tools)
|
|
{
|
|
switch (tool)
|
|
{
|
|
case AIFunction aiFunction:
|
|
runOptions.ToolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction, options));
|
|
break;
|
|
|
|
case HostedCodeInterpreterTool codeInterpreterTool:
|
|
var interpreterToolDef = ToolDefinition.CreateCodeInterpreter();
|
|
runOptions.ToolsOverride.Add(interpreterToolDef);
|
|
|
|
if (codeInterpreterTool.Inputs?.Count is > 0)
|
|
{
|
|
ThreadInitializationMessage? threadInitializationMessage = null;
|
|
foreach (var input in codeInterpreterTool.Inputs)
|
|
{
|
|
if (input is HostedFileContent hostedFile)
|
|
{
|
|
threadInitializationMessage ??= new(MessageRole.User, [MessageContent.FromText("attachments")]);
|
|
threadInitializationMessage.Attachments.Add(new(hostedFile.FileId, [interpreterToolDef]));
|
|
}
|
|
}
|
|
|
|
if (threadInitializationMessage is not null)
|
|
{
|
|
runOptions.AdditionalMessages.Add(threadInitializationMessage);
|
|
}
|
|
}
|
|
|
|
break;
|
|
|
|
case HostedFileSearchTool fileSearchTool:
|
|
runOptions.ToolsOverride.Add(ToolDefinition.CreateFileSearch(fileSearchTool.MaximumResultCount));
|
|
if (fileSearchTool.Inputs is { Count: > 0 } fileSearchInputs)
|
|
{
|
|
foreach (var input in fileSearchInputs)
|
|
{
|
|
if (input is HostedVectorStoreContent file)
|
|
{
|
|
(resources ??= new()).FileSearch ??= new();
|
|
resources.FileSearch.VectorStoreIds.Add(file.VectorStoreId);
|
|
}
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store the tool mode, if relevant.
|
|
if (runOptions.ToolConstraint is null)
|
|
{
|
|
switch (options.ToolMode)
|
|
{
|
|
case NoneChatToolMode:
|
|
runOptions.ToolConstraint = ToolConstraint.None;
|
|
break;
|
|
|
|
case AutoChatToolMode:
|
|
runOptions.ToolConstraint = ToolConstraint.Auto;
|
|
break;
|
|
|
|
case RequiredChatToolMode required when required.RequiredFunctionName is { } functionName:
|
|
runOptions.ToolConstraint = new ToolConstraint(ToolDefinition.CreateFunction(functionName));
|
|
break;
|
|
|
|
case RequiredChatToolMode required:
|
|
runOptions.ToolConstraint = ToolConstraint.Required;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Store the response format, if relevant.
|
|
if (runOptions.ResponseFormat is null)
|
|
{
|
|
switch (options.ResponseFormat)
|
|
{
|
|
case ChatResponseFormatText:
|
|
runOptions.ResponseFormat = AssistantResponseFormat.CreateTextFormat();
|
|
break;
|
|
|
|
case ChatResponseFormatJson jsonFormat when OpenAIClientExtensions2.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema:
|
|
runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonSchemaFormat(
|
|
jsonFormat.SchemaName,
|
|
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)),
|
|
jsonFormat.SchemaDescription,
|
|
OpenAIClientExtensions2.HasStrict(options.AdditionalProperties));
|
|
break;
|
|
|
|
case ChatResponseFormatJson jsonFormat:
|
|
runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonObjectFormat();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Configure system instructions.
|
|
StringBuilder? instructions = null;
|
|
void AppendSystemInstructions(string? toAppend)
|
|
{
|
|
if (!string.IsNullOrEmpty(toAppend))
|
|
{
|
|
if (instructions is null)
|
|
{
|
|
instructions = new(toAppend);
|
|
}
|
|
else
|
|
{
|
|
_ = instructions.AppendLine().AppendLine(toAppend);
|
|
}
|
|
}
|
|
}
|
|
|
|
AppendSystemInstructions(runOptions.AdditionalInstructions);
|
|
AppendSystemInstructions(options?.Instructions);
|
|
|
|
// Process ChatMessages.
|
|
List<FunctionResultContent>? functionResults = null;
|
|
foreach (var chatMessage in messages)
|
|
{
|
|
List<MessageContent> messageContents = [];
|
|
|
|
// Assistants doesn't support system/developer messages directly. It does support transient per-request instructions,
|
|
// so we can use the system/developer messages to build up a set of instructions that will be passed to the assistant
|
|
// as part of this request. However, in doing so, on a subsequent request that information will be lost, as there's no
|
|
// way to store per-thread instructions in the OpenAI Assistants API. We don't want to convert these to user messages,
|
|
// however, as that would then expose the system/developer messages in a way that might make the model more likely
|
|
// to include that information in its responses. System messages should ideally be instead done as instructions to
|
|
// the assistant when the assistant is created.
|
|
if (chatMessage.Role == ChatRole.System ||
|
|
chatMessage.Role == OpenAIClientExtensions2.ChatRoleDeveloper)
|
|
{
|
|
foreach (var textContent in chatMessage.Contents.OfType<TextContent>())
|
|
{
|
|
AppendSystemInstructions(textContent.Text);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
foreach (AIContent content in chatMessage.Contents)
|
|
{
|
|
switch (content)
|
|
{
|
|
case AIContent when content.RawRepresentation is MessageContent rawRep:
|
|
messageContents.Add(rawRep);
|
|
break;
|
|
|
|
case TextContent text:
|
|
messageContents.Add(MessageContent.FromText(text.Text));
|
|
break;
|
|
|
|
case UriContent image when image.HasTopLevelMediaType("image"):
|
|
messageContents.Add(MessageContent.FromImageUri(image.Uri));
|
|
break;
|
|
|
|
case FunctionResultContent result:
|
|
(functionResults ??= []).Add(result);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (messageContents.Count > 0)
|
|
{
|
|
runOptions.AdditionalMessages.Add(new ThreadInitializationMessage(
|
|
chatMessage.Role == ChatRole.Assistant ? MessageRole.Assistant : MessageRole.User,
|
|
messageContents));
|
|
}
|
|
}
|
|
|
|
runOptions.AdditionalInstructions = instructions?.ToString();
|
|
|
|
return (runOptions, resources, functionResults);
|
|
}
|
|
|
|
/// <summary>Convert <see cref="FunctionResultContent"/> instances to <see cref="ToolOutput"/> instances.</summary>
|
|
/// <param name="toolResults">The tool results to process.</param>
|
|
/// <param name="toolOutputs">The generated list of tool outputs, if any could be created.</param>
|
|
/// <returns>The run ID associated with the corresponding function call requests.</returns>
|
|
private static string? ConvertFunctionResultsToToolOutput(List<FunctionResultContent>? toolResults, out List<ToolOutput>? toolOutputs)
|
|
{
|
|
string? runId = null;
|
|
toolOutputs = null;
|
|
if (toolResults?.Count > 0)
|
|
{
|
|
foreach (var frc in toolResults)
|
|
{
|
|
// When creating the FunctionCallContext, we created it with a CallId == [runId, callId].
|
|
// We need to extract the run ID and ensure that the ToolOutput we send back to Azure
|
|
// is only the call ID.
|
|
string[]? runAndCallIDs;
|
|
try
|
|
{
|
|
runAndCallIDs = JsonSerializer.Deserialize(frc.CallId, OpenAIJsonContext.Default.StringArray);
|
|
}
|
|
catch
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (runAndCallIDs is null ||
|
|
runAndCallIDs.Length != 2 ||
|
|
string.IsNullOrWhiteSpace(runAndCallIDs[0]) || // run ID
|
|
string.IsNullOrWhiteSpace(runAndCallIDs[1]) || // call ID
|
|
(runId is not null && runId != runAndCallIDs[0]))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
runId = runAndCallIDs[0];
|
|
(toolOutputs ??= []).Add(new(runAndCallIDs[1], frc.Result?.ToString() ?? string.Empty));
|
|
}
|
|
}
|
|
|
|
return runId;
|
|
}
|
|
}
|
|
|
|
/// <summary>Provides extension methods for working with <see cref="OpenAIClient"/>s.</summary>
|
|
internal static class OpenAIClientExtensions2
|
|
{
|
|
/// <summary>Key into AdditionalProperties used to store a strict option.</summary>
|
|
private const string StrictKey = "strictJsonSchema";
|
|
|
|
/// <summary>Gets the default OpenAI endpoint.</summary>
|
|
internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1");
|
|
|
|
/// <summary>Gets a <see cref="ChatRole"/> for "developer".</summary>
|
|
internal static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer");
|
|
|
|
/// <summary>Creates a new instance of <see cref="FunctionCallContent"/> parsing arguments using a specified encoding and parser.</summary>
|
|
/// <param name="json">The input arguments to be parsed.</param>
|
|
/// <param name="callId">The function call ID.</param>
|
|
/// <param name="name">The function name.</param>
|
|
/// <returns>A new instance of <see cref="FunctionCallContent"/> containing the parse result.</returns>
|
|
/// <exception cref="ArgumentNullException"><paramref name="callId"/> is <see langword="null"/>.</exception>
|
|
/// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
|
|
internal static FunctionCallContent ParseCallContent(string json, string callId, string name) =>
|
|
FunctionCallContent.CreateFromParsedArguments(json, callId, name,
|
|
static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!);
|
|
|
|
/// <summary>Creates a new instance of <see cref="FunctionCallContent"/> parsing arguments using a specified encoding and parser.</summary>
|
|
/// <param name="utf8json">The input arguments to be parsed.</param>
|
|
/// <param name="callId">The function call ID.</param>
|
|
/// <param name="name">The function name.</param>
|
|
/// <returns>A new instance of <see cref="FunctionCallContent"/> containing the parse result.</returns>
|
|
/// <exception cref="ArgumentNullException"><paramref name="callId"/> is <see langword="null"/>.</exception>
|
|
/// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
|
|
internal static FunctionCallContent ParseCallContent(BinaryData utf8json, string callId, string name) =>
|
|
FunctionCallContent.CreateFromParsedArguments(utf8json, callId, name,
|
|
static utf8json => JsonSerializer.Deserialize(utf8json, OpenAIJsonContext.Default.IDictionaryStringObject)!);
|
|
|
|
/// <summary>
|
|
/// Gets the JSON schema transformer cache conforming to OpenAI <b>strict</b> / structured output restrictions per
|
|
/// https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas.
|
|
/// </summary>
|
|
internal static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new()
|
|
{
|
|
DisallowAdditionalProperties = true,
|
|
ConvertBooleanSchemas = true,
|
|
MoveDefaultKeywordToDescription = true,
|
|
RequireAllProperties = true,
|
|
TransformSchemaNode = (ctx, node) =>
|
|
{
|
|
// Move content from common but unsupported properties to description. In particular, we focus on properties that
|
|
// the AIJsonUtilities schema generator might produce and/or that are explicitly mentioned in the OpenAI documentation.
|
|
|
|
if (node is JsonObject schemaObj)
|
|
{
|
|
StringBuilder? additionalDescription = null;
|
|
|
|
ReadOnlySpan<string> unsupportedProperties =
|
|
[
|
|
// Produced by AIJsonUtilities but not in allow list at https://platform.openai.com/docs/guides/structured-outputs#supported-properties:
|
|
"contentEncoding", "contentMediaType", "not",
|
|
|
|
// Explicitly mentioned at https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#key-ordering as being unsupported with some models:
|
|
"minLength", "maxLength", "pattern", "format",
|
|
"minimum", "maximum", "multipleOf",
|
|
"patternProperties",
|
|
"minItems", "maxItems",
|
|
|
|
// Explicitly mentioned at https://learn.microsoft.com/azure/ai-services/openai/how-to/structured-outputs?pivots=programming-language-csharp&tabs=python-secure%2Cdotnet-entra-id#unsupported-type-specific-keywords
|
|
// as being unsupported with Azure OpenAI:
|
|
"unevaluatedProperties", "propertyNames", "minProperties", "maxProperties",
|
|
"unevaluatedItems", "contains", "minContains", "maxContains", "uniqueItems",
|
|
];
|
|
|
|
foreach (string propName in unsupportedProperties)
|
|
{
|
|
if (schemaObj[propName] is { } propNode)
|
|
{
|
|
_ = schemaObj.Remove(propName);
|
|
AppendLine(ref additionalDescription, propName, propNode);
|
|
}
|
|
}
|
|
|
|
if (additionalDescription is not null)
|
|
{
|
|
schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ?
|
|
$"{descriptionNode.GetValue<string>()}{Environment.NewLine}{additionalDescription}" :
|
|
additionalDescription.ToString();
|
|
}
|
|
|
|
return node;
|
|
|
|
static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode)
|
|
{
|
|
sb ??= new();
|
|
|
|
if (sb.Length > 0)
|
|
{
|
|
_ = sb.AppendLine();
|
|
}
|
|
|
|
_ = sb.Append(propName).Append(": ").Append(propNode);
|
|
}
|
|
}
|
|
|
|
return node;
|
|
},
|
|
});
|
|
|
|
// TODO: Once we're ready to rely on C# 14 features, add an extension property ChatOptions.Strict.
|
|
|
|
/// <summary>Gets whether the properties specify that strict schema handling is desired.</summary>
|
|
internal static bool? HasStrict(IReadOnlyDictionary<string, object?>? additionalProperties) =>
|
|
additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true &&
|
|
strictObj is bool strictValue ?
|
|
strictValue : null;
|
|
|
|
/// <summary>Extracts from an <see cref="AIFunction"/> the parameters and strictness setting for use with OpenAI's APIs.</summary>
|
|
internal static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, bool? strict)
|
|
{
|
|
// Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting.
|
|
JsonElement jsonSchema = strict is true ?
|
|
StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) :
|
|
aiFunction.JsonSchema;
|
|
|
|
// Roundtrip the schema through the ToolJson model type to remove extra properties
|
|
// and force missing ones into existence, then return the serialized UTF8 bytes as BinaryData.
|
|
var tool = jsonSchema.Deserialize(OpenAIJsonContext.Default.ToolJson)!;
|
|
var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.ToolJson));
|
|
|
|
return functionParameters;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Temporary extension methods to assist with creating proposed <see cref="IChatClient"/> changed instances
|
|
/// </summary>
|
|
public static class OpenAIAssistantsExtensions
|
|
{
|
|
/// <summary>
|
|
/// Creates a new instance of an <see cref="IChatClient"/> configured for the specified assistant.
|
|
/// </summary>
|
|
/// <param name="client">The <see cref="AssistantClient"/> instance used to initialize the chat client. Cannot be <see langword="null"/>.</param>
|
|
/// <param name="assistantId">The unique identifier of the assistant. Cannot be <see langword="null"/> or whitespace.</param>
|
|
/// <param name="defaultThreadId">The optional default thread identifier for the chat client. Can be <see langword="null"/>.</param>
|
|
/// <returns>A new <see cref="IChatClient"/> instance configured with the specified assistant and optional default thread.</returns>
|
|
public static IChatClient AsNewIChatClient(this AssistantClient client, string assistantId, string? defaultThreadId = null) =>
|
|
new NewOpenAIAssistantsChatClient(Throw.IfNull(client), Throw.IfNullOrWhitespace(assistantId), defaultThreadId);
|
|
}
|