Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
Expand All @@ -30,7 +29,7 @@ namespace Microsoft.Extensions.AI;

/// <summary>Represents an <see cref="IChatClient"/> for an Azure.AI.Agents.Persistent <see cref="AssistantClient"/>.</summary>
[Experimental("OPENAI001")]
internal sealed partial class OpenAIAssistantChatClient : IChatClient
internal sealed class OpenAIAssistantChatClient : IChatClient
{
/// <summary>The underlying <see cref="AssistantClient" />.</summary>
private readonly AssistantClient _client;
Expand Down Expand Up @@ -197,9 +196,9 @@ public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
{
ruUpdate.Contents.Add(
new FunctionCallContent(
JsonSerializer.Serialize([ru.Value.Id, toolCallId], AssistantJsonContext.Default.StringArray),
JsonSerializer.Serialize([ru.Value.Id, toolCallId], OpenAIJsonContext.Default.StringArray),
functionName,
JsonSerializer.Deserialize(rau.FunctionArguments, AssistantJsonContext.Default.IDictionaryStringObject)!));
JsonSerializer.Deserialize(rau.FunctionArguments, OpenAIJsonContext.Default.IDictionaryStringObject)!));
}

yield return ruUpdate;
Expand Down Expand Up @@ -237,6 +236,19 @@ void IDisposable.Dispose()
// nop
}

/// <summary>Converts an Extensions function to an OpenAI assistants function tool.</summary>
internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction)
{
(BinaryData parameters, bool? strict) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction);

return new FunctionToolDefinition(aiFunction.Name)
{
Description = aiFunction.Description,
Parameters = parameters,
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.
Expand Down Expand Up @@ -284,18 +296,7 @@ void IDisposable.Dispose()
switch (tool)
{
case AIFunction aiFunction:
bool? strict = aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out var strictValue) && strictValue is bool strictBool ?
strictBool :
null;

JsonElement jsonSchema = OpenAIClientExtensions.GetSchema(aiFunction, strict);

runOptions.ToolsOverride.Add(new FunctionToolDefinition(aiFunction.Name)
{
Description = aiFunction.Description,
Parameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, AssistantJsonContext.Default.JsonElement)),
StrictParameterSchemaEnabled = strict,
});
runOptions.ToolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction));
break;

case HostedCodeInterpreterTool:
Expand Down Expand Up @@ -340,7 +341,7 @@ void IDisposable.Dispose()
case ChatResponseFormatJson jsonFormat when OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema:
runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonSchemaFormat(
jsonFormat.SchemaName,
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, AssistantJsonContext.Default.JsonElement)),
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)),
jsonFormat.SchemaDescription);
break;

Expand Down Expand Up @@ -453,7 +454,7 @@ void AppendSystemInstructions(string? toAppend)
string[]? runAndCallIDs;
try
{
runAndCallIDs = JsonSerializer.Deserialize(frc.CallId, AssistantJsonContext.Default.StringArray);
runAndCallIDs = JsonSerializer.Deserialize(frc.CallId, OpenAIJsonContext.Default.StringArray);
}
catch
{
Expand All @@ -476,9 +477,4 @@ void AppendSystemInstructions(string? toAppend)

return runId;
}

[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(IDictionary<string, object>))]
private sealed partial class AssistantJsonContext : JsonSerializerContext;
}
61 changes: 12 additions & 49 deletions src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
Expand All @@ -23,7 +22,7 @@
namespace Microsoft.Extensions.AI;

/// <summary>Represents an <see cref="IChatClient"/> for an OpenAI <see cref="OpenAIClient"/> or <see cref="ChatClient"/>.</summary>
internal sealed partial class OpenAIChatClient : IChatClient
internal sealed class OpenAIChatClient : IChatClient
{
/// <summary>Metadata about the client.</summary>
private readonly ChatClientMetadata _metadata;
Expand Down Expand Up @@ -101,6 +100,14 @@ void IDisposable.Dispose()
// Nothing to dispose. Implementation required for the IChatClient interface.
}

/// <summary>Converts an Extensions function to an OpenAI chat tool.</summary>
internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction)
{
(BinaryData parameters, bool? strict) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction);

return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, parameters, strict);
}

/// <summary>Converts an Extensions chat message enumerable to an OpenAI chat message enumerable.</summary>
private static IEnumerable<OpenAI.Chat.ChatMessage> ToOpenAIChatMessages(IEnumerable<ChatMessage> inputs, ChatOptions? chatOptions, JsonSerializerOptions jsonOptions)
{
Expand Down Expand Up @@ -547,8 +554,7 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options)
result.ResponseFormat = OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ?
OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat(
jsonFormat.SchemaName ?? "json_schema",
BinaryData.FromBytes(
JsonSerializer.SerializeToUtf8Bytes(jsonSchema, ChatClientJsonContext.Default.JsonElement)),
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)),
jsonFormat.SchemaDescription) :
OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat();
}
Expand All @@ -557,23 +563,6 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options)
return result;
}

/// <summary>Converts an Extensions function to an OpenAI chat tool.</summary>
private static ChatTool ToOpenAIChatTool(AIFunction aiFunction)
{
bool? strict =
aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) &&
strictObj is bool strictValue ?
strictValue : null;

// Perform transformations making the schema legal per OpenAI restrictions
JsonElement jsonSchema = OpenAIClientExtensions.GetSchema(aiFunction, strict);

// Map to an intermediate model so that redundant properties are skipped.
var tool = JsonSerializer.Deserialize(jsonSchema, ChatClientJsonContext.Default.ChatToolJson)!;
var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, ChatClientJsonContext.Default.ChatToolJson));
return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict);
}

private static UsageDetails FromOpenAIUsage(ChatTokenUsage tokenUsage)
{
var destination = new UsageDetails
Expand Down Expand Up @@ -668,27 +657,11 @@ private static ChatRole FromOpenAIChatRole(ChatMessageRole role) =>

private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) =>
FunctionCallContent.CreateFromParsedArguments(json, callId, name,
argumentParser: static json => JsonSerializer.Deserialize(json, ChatClientJsonContext.Default.IDictionaryStringObject)!);
argumentParser: static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!);

private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8Json, string callId, string name) =>
FunctionCallContent.CreateFromParsedArguments(ut8Json, callId, name,
argumentParser: static json => JsonSerializer.Deserialize(json, ChatClientJsonContext.Default.IDictionaryStringObject)!);

/// <summary>Used to create the JSON payload for an OpenAI chat tool description.</summary>
private sealed class ChatToolJson
{
[JsonPropertyName("type")]
public string Type { get; set; } = "object";

[JsonPropertyName("required")]
public HashSet<string> Required { get; set; } = [];

[JsonPropertyName("properties")]
public Dictionary<string, JsonElement> Properties { get; set; } = [];

[JsonPropertyName("additionalProperties")]
public bool AdditionalProperties { get; set; }
}
argumentParser: static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!);

/// <summary>POCO representing function calling info. Used to concatenation information for a single function call from across multiple streaming updates.</summary>
private sealed class FunctionCallInfo
Expand All @@ -697,14 +670,4 @@ private sealed class FunctionCallInfo
public string? Name;
public StringBuilder? Arguments;
}

/// <summary>Source-generated JSON type information.</summary>
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true)]
[JsonSerializable(typeof(ChatToolJson))]
[JsonSerializable(typeof(IDictionary<string, object?>))]
[JsonSerializable(typeof(string[]))]
private sealed partial class ChatClientJsonContext : JsonSerializerContext;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Microsoft.Shared.Diagnostics;
using OpenAI;
using OpenAI.Assistants;
using OpenAI.Audio;
using OpenAI.Chat;
using OpenAI.Embeddings;
using OpenAI.RealtimeConversation;
using OpenAI.Responses;

#pragma warning disable S103 // Lines should not be too long
Expand All @@ -24,7 +28,7 @@ namespace Microsoft.Extensions.AI;
public static class OpenAIClientExtensions
{
/// <summary>Key into AdditionalProperties used to store a strict option.</summary>
internal const string StrictKey = "strictJsonSchema";
private const string StrictKey = "strictJsonSchema";

/// <summary>Gets the default OpenAI endpoint.</summary>
internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1");
Expand Down Expand Up @@ -106,12 +110,14 @@ static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode
/// <summary>Gets an <see cref="IChatClient"/> for use with this <see cref="ChatClient"/>.</summary>
/// <param name="chatClient">The client.</param>
/// <returns>An <see cref="IChatClient"/> that can be used to converse via the <see cref="ChatClient"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="chatClient"/> is <see langword="null"/>.</exception>
public static IChatClient AsIChatClient(this ChatClient chatClient) =>
new OpenAIChatClient(chatClient);

/// <summary>Gets an <see cref="IChatClient"/> for use with this <see cref="OpenAIResponseClient"/>.</summary>
/// <param name="responseClient">The client.</param>
/// <returns>An <see cref="IChatClient"/> that can be used to converse via the <see cref="OpenAIResponseClient"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="responseClient"/> is <see langword="null"/>.</exception>
public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient) =>
new OpenAIResponseChatClient(responseClient);

Expand All @@ -124,13 +130,17 @@ public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient
/// property. If no thread ID is provided via either mechanism, a new thread will be created for the request.
/// </param>
/// <returns>An <see cref="IChatClient"/> instance configured to interact with the specified agent and thread.</returns>
[Experimental("OPENAI001")]
/// <exception cref="ArgumentNullException"><paramref name="assistantClient"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException"><paramref name="assistantId"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="assistantId"/> is empty or composed entirely of whitespace.</exception>
[Experimental("OPENAI001")] // AssistantClient itself is experimental with this ID
public static IChatClient AsIChatClient(this AssistantClient assistantClient, string assistantId, string? threadId = null) =>
new OpenAIAssistantChatClient(assistantClient, assistantId, threadId);

/// <summary>Gets an <see cref="ISpeechToTextClient"/> for use with this <see cref="AudioClient"/>.</summary>
/// <param name="audioClient">The client.</param>
/// <returns>An <see cref="ISpeechToTextClient"/> that can be used to transcribe audio via the <see cref="AudioClient"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="audioClient"/> is <see langword="null"/>.</exception>
[Experimental("MEAI001")]
public static ISpeechToTextClient AsISpeechToTextClient(this AudioClient audioClient) =>
new OpenAISpeechToTextClient(audioClient);
Expand All @@ -139,12 +149,74 @@ public static ISpeechToTextClient AsISpeechToTextClient(this AudioClient audioCl
/// <param name="embeddingClient">The client.</param>
/// <param name="defaultModelDimensions">The number of dimensions to generate in each embedding.</param>
/// <returns>An <see cref="IEmbeddingGenerator{String, Embedding}"/> that can be used to generate embeddings via the <see cref="EmbeddingClient"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="embeddingClient"/> is <see langword="null"/>.</exception>
public static IEmbeddingGenerator<string, Embedding<float>> AsIEmbeddingGenerator(this EmbeddingClient embeddingClient, int? defaultModelDimensions = null) =>
new OpenAIEmbeddingGenerator(embeddingClient, defaultModelDimensions);

/// <summary>Gets the JSON schema to use from the function.</summary>
internal static JsonElement GetSchema(AIFunction function, bool? strict) =>
strict is true ?
StrictSchemaTransformCache.GetOrCreateTransformedSchema(function) :
function.JsonSchema;
/// <summary>Creates an OpenAI <see cref="ChatTool"/> from an <see cref="AIFunction"/>.</summary>
/// <param name="function">The function to convert.</param>
/// <returns>An OpenAI <see cref="ChatTool"/> representing <paramref name="function"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="function"/> is <see langword="null"/>.</exception>
public static ChatTool AsOpenAIChatTool(this AIFunction function) =>
OpenAIChatClient.ToOpenAIChatTool(Throw.IfNull(function));

/// <summary>Creates an OpenAI <see cref="FunctionToolDefinition"/> from an <see cref="AIFunction"/>.</summary>
/// <param name="function">The function to convert.</param>
/// <returns>An OpenAI <see cref="FunctionToolDefinition"/> representing <paramref name="function"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="function"/> is <see langword="null"/>.</exception>
[Experimental("OPENAI001")] // AssistantClient itself is experimental with this ID
public static FunctionToolDefinition AsOpenAIAssistantsFunctionToolDefinition(this AIFunction function) =>
OpenAIAssistantChatClient.ToOpenAIAssistantsFunctionToolDefinition(Throw.IfNull(function));

/// <summary>Creates an OpenAI <see cref="ResponseTool"/> from an <see cref="AIFunction"/>.</summary>
/// <param name="function">The function to convert.</param>
/// <returns>An OpenAI <see cref="ResponseTool"/> representing <paramref name="function"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="function"/> is <see langword="null"/>.</exception>
public static ResponseTool AsOpenAIResponseTool(this AIFunction function) =>
OpenAIResponseChatClient.ToResponseTool(Throw.IfNull(function));

/// <summary>Creates an OpenAI <see cref="ConversationFunctionTool"/> from an <see cref="AIFunction"/>.</summary>
/// <param name="function">The function to convert.</param>
/// <returns>An OpenAI <see cref="ConversationFunctionTool"/> representing <paramref name="function"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="function"/> is <see langword="null"/>.</exception>
public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction function) =>
OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function));

/// <summary>Extracts from an <see cref="AIFunction"/> the parameters and strictness setting for use with OpenAI's APIs.</summary>
internal static (BinaryData Parameters, bool? Strict) ToOpenAIFunctionParameters(AIFunction aiFunction)
{
// Extract any strict setting from AdditionalProperties.
bool? strict =
aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) &&
strictObj is bool strictValue ?
strictValue : null;

// 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 = JsonSerializer.Deserialize(jsonSchema, OpenAIJsonContext.Default.ToolJson)!;
var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.ToolJson));

return (functionParameters, strict);
}

/// <summary>Used to create the JSON payload for an OpenAI tool description.</summary>
internal sealed class ToolJson
{
[JsonPropertyName("type")]
public string Type { get; set; } = "object";

[JsonPropertyName("required")]
public HashSet<string> Required { get; set; } = [];

[JsonPropertyName("properties")]
public Dictionary<string, JsonElement> Properties { get; set; } = [];

[JsonPropertyName("additionalProperties")]
public bool AdditionalProperties { get; set; }
}
}
Loading
Loading