From bb8ef466de7a3ca6b51a83803d6fc91c8d63ed61 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 5 Nov 2025 10:55:26 +0100 Subject: [PATCH] .NET: Improve fidelity of OpenAI ChatCompletions Hosting (#1785) * rename, support json serialization * wip * non-streaming * streaming? * proper streaming types * comments + fix audio parse * copilot suggestions * proper stopsequences type * build options as i could * annotations * proper generation of Id for chatcompletions * string length as in chatcompletions api ref * image url * support tools * rework API * introduce tests for chatcompletions * function calling / serialization tests / fixes * more tests and coverage * fix format * sort usings * nit * address PR comments * nits --- .../AgentWebChat.AgentHost/Program.cs | 10 +- .../AIAgentChatCompletionsProcessor.cs | 157 ++- .../AgentRunResponseExtensions.cs | 209 ++++ .../ChatCompletionsJsonContext.cs | 63 ++ .../ChatCompletionsJsonSerializerOptions.cs | 24 + .../ChatClientAgentRunOptionsConverter.cs | 118 +++ .../Converters/MessageContentPartConverter.cs | 59 ++ .../ChatCompletions/Models/ChatCompletion.cs | 68 ++ .../Models/ChatCompletionChoice.cs | 216 ++++ .../Models/ChatCompletionChunk.cs | 121 +++ .../Models/ChatCompletionRequestMessage.cs | 175 ++++ .../ChatCompletions/Models/CompletionUsage.cs | 133 +++ .../Models/CreateChatCompletion.cs | 258 +++++ .../ChatCompletions/Models/MessageContent.cs | 167 +++ .../Models/MessageContentPart.cs | 160 +++ .../ChatCompletions/Models/ResponseFormat.cs | 282 +++++ .../ChatCompletions/Models/StopSequences.cs | 193 ++++ .../ChatCompletions/Models/Tool.cs | 164 +++ .../ChatCompletions/Models/ToolChoice.cs | 384 +++++++ .../Utils/ChatCompletionsOptionsExtensions.cs | 52 - ...tRouteBuilderExtensions.ChatCompletions.cs | 82 +- .../HostApplicationBuilderExtensions.cs | 16 +- .../IdGeneratorHelpers.cs | 98 ++ .../Responses/IdGenerator.cs | 96 +- .../ServiceCollectionExtensions.cs | 17 +- .../ConformanceTestBase.cs | 78 +- .../ChatCompletions/basic/request.json | 12 + .../ChatCompletions/basic/response.json | 33 + .../function_calling/request.json | 34 + .../function_calling/response.json | 43 + .../ChatCompletions/json_mode/request.json | 36 + .../ChatCompletions/json_mode/response.json | 33 + .../ChatCompletions/multi_turn/request.json | 18 + .../ChatCompletions/multi_turn/response.json | 33 + .../ChatCompletions/streaming/request.json | 12 + .../ChatCompletions/streaming/response.txt | 21 + .../system_message/request.json | 14 + .../system_message/response.json | 33 + .../ContentTypeEventGeneratorTests.cs | 38 +- ....Agents.AI.Hosting.OpenAI.UnitTests.csproj | 14 +- .../OpenAIChatCompletionsConformanceTests.cs | 495 +++++++++ .../OpenAIChatCompletionsIntegrationTests.cs | 974 ++++++++++++++++++ ...OpenAIChatCompletionsSerializationTests.cs | 576 +++++++++++ .../OpenAIResponsesConformanceTests.cs | 78 +- .../OpenAIResponsesSerializationTests.cs | 94 +- .../StreamingEventConformanceTests.cs | 102 +- 46 files changed, 5687 insertions(+), 406 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentRunResponseExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonSerializerOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/MessageContentPartConverter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletion.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChoice.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChunk.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CompletionUsage.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CreateChatCompletion.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContent.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContentPart.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ResponseFormat.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/StopSequences.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ToolChoice.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Utils/ChatCompletionsOptionsExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGeneratorHelpers.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/request.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/response.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/request.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/response.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/request.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/response.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/request.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/response.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/request.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/response.txt create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/request.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/response.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsIntegrationTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsSerializationTests.cs diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 571b07b1d5..d86c53958d 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -20,14 +20,14 @@ builder.Services.AddProblemDetails(); // Configure the chat model and our agent. builder.AddKeyedChatClient("chat-model"); -builder.AddAIAgent( +var pirateAgentBuilder = builder.AddAIAgent( "pirate", instructions: "You are a pirate. Speak like a pirate", description: "An agent that speaks like a pirate.", chatClientServiceKey: "chat-model") .WithInMemoryThreadStore(); -builder.AddAIAgent("knights-and-knaves", (sp, key) => +var knightsKnavesAgentBuilder = builder.AddAIAgent("knights-and-knaves", (sp, key) => { var chatClient = sp.GetRequiredKeyedService("chat-model"); @@ -80,6 +80,8 @@ var literatureAgent = builder.AddAIAgent("literator", builder.AddSequentialWorkflow("science-sequential-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent(); builder.AddConcurrentWorkflow("science-concurrent-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent(); + +builder.AddOpenAIChatCompletions(); builder.AddOpenAIResponses(); var app = builder.Build(); @@ -104,8 +106,8 @@ app.MapA2A(agentName: "knights-and-knaves", path: "/a2a/knights-and-knaves", age app.MapOpenAIResponses(); -app.MapOpenAIChatCompletions("pirate"); -app.MapOpenAIChatCompletions("knights-and-knaves"); +app.MapOpenAIChatCompletions(pirateAgentBuilder); +app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder); // Map the agents HTTP endpoints app.MapAgentDiscovery("/agents"); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs index f32fcc8db8..86eb57b7c3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs @@ -1,70 +1,44 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Buffers; -using System.ClientModel.Primitives; +using System; using System.Collections.Generic; -using System.Diagnostics; +using System.Linq; using System.Net.ServerSentEvents; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Utils; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; -using OpenAI.Chat; -using ChatMessage = Microsoft.Extensions.AI.ChatMessage; +using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; -internal sealed class AIAgentChatCompletionsProcessor +internal static class AIAgentChatCompletionsProcessor { - private readonly AIAgent _agent; - - public AIAgentChatCompletionsProcessor(AIAgent agent) + public static async Task CreateChatCompletionAsync(AIAgent agent, CreateChatCompletion request, CancellationToken cancellationToken) { - this._agent = agent; - } + ArgumentNullException.ThrowIfNull(agent); - public async Task CreateChatCompletionAsync(ChatCompletionOptions chatCompletionOptions, CancellationToken cancellationToken) - { - AgentThread? agentThread = null; // not supported to resolve from conversationId + var chatMessages = request.Messages.Select(i => i.ToChatMessage()); + var chatClientAgentRunOptions = request.BuildOptions(); - var inputItems = chatCompletionOptions.GetMessages(); - var chatMessages = inputItems.AsChatMessages(); - - if (chatCompletionOptions.GetStream()) + if (request.Stream == true) { - return new OpenAIStreamingChatCompletionResult(this._agent, chatMessages); + return new StreamingResponse(agent, request, chatMessages, chatClientAgentRunOptions); } - var agentResponse = await this._agent.RunAsync(chatMessages, agentThread, cancellationToken: cancellationToken).ConfigureAwait(false); - return new OpenAIChatCompletionResult(agentResponse); + var response = await agent.RunAsync(chatMessages, options: chatClientAgentRunOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + return Results.Ok(response.ToChatCompletion(request)); } - private sealed class OpenAIChatCompletionResult(AgentRunResponse agentRunResponse) : IResult - { - public async Task ExecuteAsync(HttpContext httpContext) - { - // note: OpenAI SDK types provide their own serialization implementation - // so we cant simply return IResult wrap for the typed-object. - // instead writing to the response body can be done. - - var cancellationToken = httpContext.RequestAborted; - var response = httpContext.Response; - - var chatResponse = agentRunResponse.AsChatResponse(); - var openAIChatCompletion = chatResponse.AsOpenAIChatCompletion(); - var openAIChatCompletionJsonModel = openAIChatCompletion as IJsonModel; - Debug.Assert(openAIChatCompletionJsonModel is not null); - - var writer = new Utf8JsonWriter(response.BodyWriter, new JsonWriterOptions { SkipValidation = false }); - openAIChatCompletionJsonModel.Write(writer, ModelReaderWriterOptions.Json); - await writer.FlushAsync(cancellationToken).ConfigureAwait(false); - } - } - - private sealed class OpenAIStreamingChatCompletionResult(AIAgent agent, IEnumerable chatMessages) : IResult + private sealed class StreamingResponse( + AIAgent agent, + CreateChatCompletion request, + IEnumerable chatMessages, + ChatClientAgentRunOptions? options) : IResult { public Task ExecuteAsync(HttpContext httpContext) { @@ -79,26 +53,99 @@ internal sealed class AIAgentChatCompletionsProcessor httpContext.Features.GetRequiredFeature().DisableBuffering(); return SseFormatter.WriteAsync( - source: this.GetStreamingResponsesAsync(cancellationToken), + source: this.GetStreamingChunksAsync(cancellationToken), destination: response.Body, itemFormatter: (sseItem, bufferWriter) => { - var sseDataJsonModel = (IJsonModel)sseItem.Data; - var json = sseDataJsonModel.Write(ModelReaderWriterOptions.Json); - bufferWriter.Write(json); + using var writer = new Utf8JsonWriter(bufferWriter); + JsonSerializer.Serialize(writer, sseItem.Data, ChatCompletionsJsonContext.Default.ChatCompletionChunk); + writer.Flush(); }, cancellationToken); } - private async IAsyncEnumerable> GetStreamingResponsesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + private async IAsyncEnumerable> GetStreamingChunksAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) { - AgentThread? agentThread = null; + // The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same timestamp. + DateTimeOffset? createdAt = null; + var chunkId = IdGeneratorHelpers.NewId(prefix: "chatcmpl", delimiter: "-", stringLength: 13); - var agentRunResponseUpdates = agent.RunStreamingAsync(chatMessages, thread: agentThread, cancellationToken: cancellationToken); - var chatResponseUpdates = agentRunResponseUpdates.AsChatResponseUpdatesAsync(); - await foreach (var streamingChatCompletionUpdate in chatResponseUpdates.AsOpenAIStreamingChatCompletionUpdatesAsync(cancellationToken).ConfigureAwait(false)) + await foreach (var agentRunResponseUpdate in agent.RunStreamingAsync(chatMessages, options: options, cancellationToken: cancellationToken).WithCancellation(cancellationToken)) { - yield return new SseItem(streamingChatCompletionUpdate); + var finishReason = (agentRunResponseUpdate.RawRepresentation is ChatResponseUpdate { FinishReason: not null } chatResponseUpdate) + ? chatResponseUpdate.FinishReason.ToString() + : "stop"; + + var choiceChunks = new List(); + CompletionUsage? usageDetails = null; + + createdAt ??= agentRunResponseUpdate.CreatedAt; + + foreach (var content in agentRunResponseUpdate.Contents) + { + // usage content is handled separately + if (content is UsageContent usageContent && usageContent.Details != null) + { + usageDetails = usageContent.Details.ToCompletionUsage(); + continue; + } + + ChatCompletionDelta? delta = content switch + { + TextContent textContent => new() { Content = textContent.Text }, + + // image + DataContent imageContent when imageContent.HasTopLevelMediaType("image") => new() { Content = imageContent.Base64Data.ToString() }, + UriContent urlContent when urlContent.HasTopLevelMediaType("image") => new() { Content = urlContent.Uri.ToString() }, + + // audio + DataContent audioContent when audioContent.HasTopLevelMediaType("audio") => new() { Content = audioContent.Base64Data.ToString() }, + + // file + DataContent fileContent => new() { Content = fileContent.Base64Data.ToString() }, + HostedFileContent fileContent => new() { Content = fileContent.FileId }, + + // function call + FunctionCallContent functionCallContent => new() + { + ToolCalls = [functionCallContent.ToChoiceMessageToolCall()] + }, + + // function result. ChatCompletions dont provide the results of function result per API reference + FunctionResultContent functionResultContent => null, + + // ignore + _ => null + }; + + if (delta is null) + { + // unsupported but expected content type. + continue; + } + + delta.Role = agentRunResponseUpdate.Role?.Value ?? "user"; + + var choiceChunk = new ChatCompletionChoiceChunk + { + Index = 0, + Delta = delta, + FinishReason = finishReason + }; + + choiceChunks.Add(choiceChunk); + } + + var chunk = new ChatCompletionChunk + { + Id = chunkId, + Created = (createdAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(), + Model = request.Model, + Choices = choiceChunks, + Usage = usageDetails + }; + + yield return new(chunk); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentRunResponseExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentRunResponseExtensions.cs new file mode 100644 index 0000000000..9674b261a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AgentRunResponseExtensions.cs @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; + +/// +/// Extension methods for converting agent responses to ChatCompletion models. +/// +internal static class AgentRunResponseExtensions +{ + public static ChatCompletion ToChatCompletion(this AgentRunResponse agentRunResponse, CreateChatCompletion request) + { + IList choices = agentRunResponse.ToChoices(); + + return new ChatCompletion + { + Id = IdGeneratorHelpers.NewId(prefix: "chatcmpl", delimiter: "-", stringLength: 13), + Choices = choices, + Created = (agentRunResponse.CreatedAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(), + Model = request.Model, + Usage = agentRunResponse.Usage.ToCompletionUsage(), + ServiceTier = request.ServiceTier ?? "default" + }; + } + + public static List ToChoices(this AgentRunResponse agentRunResponse) + { + var chatCompletionChoices = new List(); + var index = 0; + + var finishReason = (agentRunResponse.RawRepresentation is ChatResponse { FinishReason: not null } chatResponse) + ? chatResponse.FinishReason.ToString() + : "stop"; // "stop" is a natural stop point; returning this by-default + + foreach (var message in agentRunResponse.Messages) + { + foreach (var content in message.Contents) + { + ChoiceMessage? choiceMessage = content switch + { + // text + TextContent textContent => new() + { + Content = textContent.Text + }, + + // image, see how MessageContentPartConverter packs the content types + DataContent imageContent when imageContent.HasTopLevelMediaType("image") => new() + { + Content = imageContent.Base64Data.ToString() + }, + UriContent urlContent when urlContent.HasTopLevelMediaType("image") => new() + { + Content = urlContent.Uri.ToString() + }, + + // audio + DataContent audioContent when audioContent.HasTopLevelMediaType("audio") => new() + { + Audio = new() + { + Data = audioContent.Base64Data.ToString(), + Id = audioContent.Name, + //Transcript = , + //ExpiresAt = , + }, + }, + + // file (neither audio nor image) + DataContent fileContent => new() + { + Content = fileContent.Base64Data.ToString() + }, + HostedFileContent fileContent => new() + { + Content = fileContent.FileId + }, + + // function call + FunctionCallContent functionCallContent => new() + { + ToolCalls = [functionCallContent.ToChoiceMessageToolCall()] + }, + + // function result. ChatCompletions dont provide the results of function result per API reference + FunctionResultContent functionResultContent => null, + + // ignore + _ => null + }; + + if (choiceMessage is null) + { + // not supported, but expected content type. + continue; + } + + choiceMessage.Role = message.Role.Value; + choiceMessage.Annotations = content.Annotations?.ToChoiceMessageAnnotations(); + + var choice = new ChatCompletionChoice + { + Index = index++, + Message = choiceMessage, + FinishReason = finishReason + }; + + chatCompletionChoices.Add(choice); + } + } + + return chatCompletionChoices; + } + + /// + /// Converts UsageDetails to CompletionUsage. + /// + /// The usage details to convert. + /// A CompletionUsage object with zeros if usage is null. + public static CompletionUsage ToCompletionUsage(this UsageDetails? usage) + { + if (usage == null) + { + return CompletionUsage.Zero; + } + + var cachedTokens = usage.AdditionalCounts?.TryGetValue("InputTokenDetails.CachedTokenCount", out var cachedInputToken) ?? false + ? (int)cachedInputToken + : 0; + var reasoningTokens = + usage.AdditionalCounts?.TryGetValue("OutputTokenDetails.ReasoningTokenCount", out var reasoningToken) ?? false + ? (int)reasoningToken + : 0; + + return new CompletionUsage + { + PromptTokens = (int)(usage.InputTokenCount ?? 0), + PromptTokensDetails = new() { CachedTokens = cachedTokens }, + CompletionTokens = (int)(usage.OutputTokenCount ?? 0), + CompletionTokensDetails = new() { ReasoningTokens = reasoningTokens }, + TotalTokens = (int)(usage.TotalTokenCount ?? 0) + }; + } + + public static IList ToChoiceMessageAnnotations(this IList annotations) + { + var result = new List(); + foreach (var annotation in annotations.OfType()) + { + if (annotation is null) + { + continue; + } + + // may point to mulitple regions in the AIContent. + // we need to unroll another loop for regions then -> chatCompletions only point to single region per annotation + + var regions = annotation.AnnotatedRegions?.OfType().Where(x => x.StartIndex is not null && x.EndIndex is not null); + if (regions is not null) + { + foreach (var region in regions) + { + result.Add(new() + { + AnnotationUrlCitation = new AnnotationUrlCitation + { + Url = annotation.Url?.ToString(), + Title = annotation.Title, + StartIndex = region.StartIndex, + EndIndex = region.EndIndex + } + }); + } + } + else + { + result.Add(new() + { + AnnotationUrlCitation = new AnnotationUrlCitation + { + Url = annotation.Url?.ToString(), + Title = annotation.Title + } + }); + } + } + + return result; + } + + public static ChoiceMessageToolCall ToChoiceMessageToolCall(this FunctionCallContent functionCall) + { + return new() + { + Id = functionCall.CallId, + Function = new() + { + Name = functionCall.Name, + Arguments = JsonSerializer.Serialize(functionCall.Arguments, ChatCompletionsJsonContext.Default.DictionaryStringObject) + } + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonContext.cs new file mode 100644 index 0000000000..25aa47dfb7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonContext.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; + +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + AllowOutOfOrderMetadataProperties = true, + WriteIndented = false)] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(CreateChatCompletion))] +[JsonSerializable(typeof(StopSequences))] +[JsonSerializable(typeof(ChatCompletion))] +[JsonSerializable(typeof(ChatCompletionRequestMessage))] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(MessageContent))] +[JsonSerializable(typeof(MessageContentPart))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(TextContentPart))] +[JsonSerializable(typeof(ImageContentPart))] +[JsonSerializable(typeof(AudioContentPart))] +[JsonSerializable(typeof(FileContentPart))] +[JsonSerializable(typeof(ChatCompletionChoice))] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(ChoiceMessage))] +[JsonSerializable(typeof(ChoiceMessageAnnotation))] +[JsonSerializable(typeof(ChoiceMessageAudio))] +[JsonSerializable(typeof(ChoiceMessageFunctionCall))] +[JsonSerializable(typeof(ChoiceMessageToolCall))] +[JsonSerializable(typeof(AnnotationUrlCitation))] +[JsonSerializable(typeof(ChatCompletionChoiceChunk))] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(ChatCompletionChunk))] +[JsonSerializable(typeof(ChatCompletionDelta))] +[JsonSerializable(typeof(ToolChoice))] +[JsonSerializable(typeof(AllowedToolsChoice))] +[JsonSerializable(typeof(AllowedToolsConfiguration))] +[JsonSerializable(typeof(ToolDefinition))] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(FunctionReference))] +[JsonSerializable(typeof(FunctionToolChoice))] +[JsonSerializable(typeof(CustomToolChoice))] +[JsonSerializable(typeof(CustomToolObject))] +[JsonSerializable(typeof(ResponseFormat))] +[JsonSerializable(typeof(TextResponseFormat))] +[JsonSerializable(typeof(JsonSchemaResponseFormat))] +[JsonSerializable(typeof(JsonSchemaConfiguration))] +[JsonSerializable(typeof(JsonObjectResponseFormat))] +[JsonSerializable(typeof(Tool))] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(FunctionTool))] +[JsonSerializable(typeof(FunctionDefinition))] +[JsonSerializable(typeof(CustomTool))] +[JsonSerializable(typeof(CustomToolProperties))] +[JsonSerializable(typeof(CustomToolFormat))] +[ExcludeFromCodeCoverage] +internal sealed partial class ChatCompletionsJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonSerializerOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonSerializerOptions.cs new file mode 100644 index 0000000000..b009b82d29 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/ChatCompletionsJsonSerializerOptions.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; + +/// +/// Extension methods for JSON serialization. +/// +internal static class ChatCompletionsJsonSerializerOptions +{ + /// + /// Gets the default JSON serializer options. + /// + public static JsonSerializerOptions Default { get; } = Create(); + + private static JsonSerializerOptions Create() + { + JsonSerializerOptions options = new(ChatCompletionsJsonContext.Default.Options); + options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!); + options.MakeReadOnly(); + return options; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs new file mode 100644 index 0000000000..5f50251f74 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/ChatClientAgentRunOptionsConverter.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Text.Json; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters; + +internal static class ChatClientAgentRunOptionsConverter +{ + private static readonly JsonElement s_emptyJson = JsonDocument.Parse("{}").RootElement; + + public static ChatClientAgentRunOptions BuildOptions(this CreateChatCompletion request) + { + ChatOptions chatOptions = new() + { + Temperature = request.Temperature, + MaxOutputTokens = request.MaxCompletionTokens, + FrequencyPenalty = request.FrequencyPenalty, + PresencePenalty = request.PresencePenalty, + Seed = request.Seed, + TopP = request.TopP, + StopSequences = request.Stop?.SequenceList ?? [], + ResponseFormat = request.ResponseFormat?.ToChatResponseFormat() + }; + + if (request.ToolChoice is not null) + { + chatOptions.ToolMode = request.ToolChoice.ToChatToolMode(); + } + + if (request.Tools?.Count > 0) + { + chatOptions.Tools = request.Tools.Select(x => x.ToAITool()).ToList(); + } + + return new() + { + ChatOptions = chatOptions + }; + } + + private static ChatResponseFormat ToChatResponseFormat(this ResponseFormat responseFormat) + { + if (responseFormat.IsText) + { + return ChatResponseFormat.Text; + } + if (responseFormat.IsJsonObject) + { + return ChatResponseFormat.Json; + } + if (responseFormat.IsJsonSchema) + { + var schema = responseFormat.JsonSchema.JsonSchema; + return ChatResponseFormat.ForJsonSchema(schema.Schema, schema.Name, schema.Description); + } + + throw new ArgumentOutOfRangeException(nameof(responseFormat)); + } + + private static AITool ToAITool(this Tool tool) + { + if (tool is FunctionTool functionTool) + { + var function = functionTool.Function; + return AIFunctionFactory.CreateDeclaration(function.Name, function.Description, function.Parameters ?? s_emptyJson); + } + if (tool is CustomTool customTool) + { + var custom = customTool.Custom; + return new CustomAITool(custom.Name, custom.Description, custom.Format?.AdditionalProperties); + } + + throw new ArgumentOutOfRangeException(nameof(tool)); + } + + private static ChatToolMode? ToChatToolMode(this ToolChoice toolChoice) + { + if (toolChoice.IsMode) + { + return toolChoice.Mode switch + { + "auto" => ChatToolMode.Auto, + "none" => ChatToolMode.None, + "required" => ChatToolMode.RequireAny, + _ => null + }; + } + + if (toolChoice.IsAllowedTools) + { + var mode = toolChoice.AllowedTools.AllowedTools.Mode; + return mode switch + { + "auto" => ChatToolMode.Auto, + "required" => ChatToolMode.RequireAny, + _ => null + }; + } + + if (toolChoice.IsFunctionTool) + { + var function = toolChoice.FunctionTool.Function; + return ChatToolMode.RequireSpecific(function.Name); + } + + if (toolChoice.IsCustomTool) + { + var custom = toolChoice.CustomTool.Custom; + return ChatToolMode.RequireSpecific(custom.Name); + } + + throw new ArgumentOutOfRangeException(nameof(toolChoice)); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/MessageContentPartConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/MessageContentPartConverter.cs new file mode 100644 index 0000000000..f646010ac4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Converters/MessageContentPartConverter.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters; + +internal static class MessageContentPartConverter +{ + public static AIContent? ToAIContent(MessageContentPart part) + { + return part switch + { + // text + TextContentPart textPart => new TextContent(textPart.Text), + + // image + ImageContentPart imagePart when !string.IsNullOrEmpty(imagePart.UrlOrData) => + imagePart.UrlOrData.StartsWith("data:", StringComparison.OrdinalIgnoreCase) + ? new DataContent(imagePart.UrlOrData, "image/*") + : new UriContent(imagePart.Url, ImageUriToMediaType(imagePart.Url)), + + // audio + AudioContentPart audioPart => + new DataContent(audioPart.InputAudio.Data, audioPart.InputAudio.Format.ToUpperInvariant() switch + { + "MP3" => "audio/mpeg", + "WAV" => "audio/wav", + "OPUS" => "audio/opus", + "AAC" => "audio/aac", + "FLAC" => "audio/flac", + "PCM16" => "audio/pcm", + _ => "audio/*" + }), + + // file + FileContentPart filePart when !string.IsNullOrEmpty(filePart.File.FileId) + => new HostedFileContent(filePart.File.FileId), + FileContentPart filePart when !string.IsNullOrEmpty(filePart.File.FileData) + => new DataContent(filePart.File.FileData, "application/octet-stream") { Name = filePart.File.Filename }, + + _ => null + }; + } + + private static string ImageUriToMediaType(Uri uri) + { + string absoluteUri = uri.AbsoluteUri; + return + absoluteUri.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? "image/png" : + absoluteUri.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : + absoluteUri.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : + absoluteUri.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ? "image/gif" : + absoluteUri.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase) ? "image/bmp" : + absoluteUri.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) ? "image/webp" : + "image/*"; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletion.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletion.cs new file mode 100644 index 0000000000..ccd15d5983 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletion.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents a chat completion response returned by the model, based on the provided input. +/// +internal sealed record ChatCompletion +{ + /// + /// A unique identifier for the chat completion. + /// + [JsonPropertyName("id")] + [JsonRequired] + public required string Id { get; init; } + + /// + /// The object type, which is always "chat.completion". + /// + [JsonPropertyName("object")] + public string Object { get; init; } = "chat.completion"; + + /// + /// The Unix timestamp (in seconds) of when the chat completion was created. + /// + [JsonPropertyName("created")] + [JsonRequired] + public required long Created { get; init; } + + /// + /// The model used for the chat completion. + /// + [JsonPropertyName("model")] + [JsonRequired] + public required string Model { get; init; } + + /// + /// A list of chat completion choices. Can be more than one if n is greater than 1. + /// + [JsonPropertyName("choices")] + [JsonRequired] + public required IList Choices { get; init; } + + /// + /// Usage statistics for the completion request. + /// + [JsonPropertyName("usage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CompletionUsage? Usage { get; init; } + + /// + /// The service tier used for processing the request. This field is only included if the service_tier parameter is specified in the request. + /// + [JsonPropertyName("service_tier")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ServiceTier { get; init; } + + /// + /// This fingerprint represents the backend configuration that the model runs with. + /// Can be used in conjunction with the seed request parameter to understand when backend changes have been made that might impact determinism. + /// + [JsonPropertyName("system_fingerprint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SystemFingerprint { get; init; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChoice.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChoice.cs new file mode 100644 index 0000000000..70de23e021 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChoice.cs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents a choice in a chat completion response. +/// +internal sealed record ChatCompletionChoice +{ + /// + /// The index of the choice in the list of choices. + /// + [JsonPropertyName("index")] + public required int Index { get; init; } + + /// + /// The reason the model stopped generating tokens. + /// This will be stop if the model hit a natural stop point or a provided stop sequence, length if the maximum number of tokens specified in the request was reached, + /// content_filter if content was omitted due to a flag from our content filters, tool_calls if the model called a tool, + /// or function_call (deprecated) if the model called a function. + /// + [JsonPropertyName("finish_reason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FinishReason { get; init; } + + /// + /// A chat completion message generated by the model. + /// + [JsonPropertyName("message")] + public required ChoiceMessage Message { get; init; } +} + +/// +/// A chat completion message generated by the model. +/// +internal sealed record ChoiceMessage +{ + /// + /// The role of the author of this message. + /// + [JsonPropertyName("role")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Role { get; set; } + + /// + /// A list of annotations for this message. Currently used for web search citations. + /// + [JsonPropertyName("annotations")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Annotations { get; set; } + + /// + /// The contents of the message. + /// + [JsonPropertyName("content")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Content { get; set; } + + /// + /// The refusal message generated by the model. + /// + [JsonPropertyName("refusal")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Refusal { get; set; } + + /// + /// If the audio output modality is requested, this object contains data about the audio response from the model. + /// + [JsonPropertyName("audio")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChoiceMessageAudio? Audio { get; set; } + + /// + /// Deprecated and replaced by tool_calls. The name and arguments of a function that should be called, as generated by the model. + /// + [JsonPropertyName("function_call")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChoiceMessageFunctionCall? FunctionCall { get; set; } + + /// + /// The tool calls generated by the model, such as function calls. + /// + [JsonPropertyName("tool_calls")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? ToolCalls { get; set; } +} + +/// +/// Audio output data in a chat completion message. +/// +internal sealed record ChoiceMessageAudio +{ + /// + /// Base64 encoded audio bytes generated by the model, in the format specified in the request. + /// + [JsonPropertyName("data")] + public string? Data { get; init; } + + /// + /// The Unix timestamp (in seconds) for when this audio response will no longer be accessible on the server for use in multi-turn conversations. + /// + [JsonPropertyName("expires_at")] + public int ExpiresAt { get; init; } + + /// + /// Unique identifier for this audio response. + /// + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; init; } + + /// + /// Transcript of the audio generated by the model. + /// + [JsonPropertyName("transcript")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Transcript { get; init; } +} + +/// +/// Deprecated. The name and arguments of a function that should be called, as generated by the model. +/// +internal sealed record ChoiceMessageFunctionCall +{ + /// + /// The name of the function to call. + /// + [JsonPropertyName("name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; init; } + + /// + /// The arguments to call the function with, as generated by the model in JSON format. + /// Note that the model does not always generate valid JSON, and may hallucinate parameters not defined by your function schema. + /// Validate the arguments in your code before calling your function. + /// + [JsonPropertyName("arguments")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Arguments { get; init; } +} + +/// +/// Represents a tool call generated by the model. +/// +internal sealed record ChoiceMessageToolCall +{ + /// + /// The ID of the tool call. + /// + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; init; } + + /// + /// The type of the tool. + /// + public string Type => "function"; + + /// + /// The function that the model called. + /// + [JsonPropertyName("function")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChoiceMessageFunctionCall? Function { get; set; } +} + +/// +/// An annotation for a message, used for web search citations. +/// +internal sealed record ChoiceMessageAnnotation +{ + /// + /// The type of annotation. Always 'url_citation' for web search results. + /// + [JsonPropertyName("type")] + public string Type => "url_citation"; + + /// + /// The URL citation details. + /// + [JsonPropertyName("url_citation")] + public required AnnotationUrlCitation AnnotationUrlCitation { get; init; } +} + +/// +/// A citation to a URL for a web search result. +/// +internal sealed record AnnotationUrlCitation +{ + /// + /// The character index in the message content where the citation ends. + /// + [JsonPropertyName("end_index")] + public int? EndIndex { get; init; } + + /// + /// The character index in the message content where the citation starts. + /// + [JsonPropertyName("start_index")] + public int? StartIndex { get; init; } + + /// + /// The title of the cited resource. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The URL of the cited resource. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChunk.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChunk.cs new file mode 100644 index 0000000000..204c5c07b3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionChunk.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents a chunk of chat completion response returned by the model, based on the provided input. +/// +internal sealed record ChatCompletionChunk +{ + /// + /// A unique identifier for the chat completion. Each chunk has the same ID. + /// + [JsonPropertyName("id")] + [JsonRequired] + public required string Id { get; init; } + + /// + /// A list of chat completion choices. Can be more than one if n is greater than 1. + /// + [JsonPropertyName("choices")] + [JsonRequired] + public required IList Choices { get; init; } + + /// + /// The object type, which is always "chat.completion.chunk". + /// + [JsonPropertyName("object")] + public string Object => "chat.completion.chunk"; + + /// + /// The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same timestamp. + /// + [JsonPropertyName("created")] + [JsonRequired] + public required long Created { get; init; } + + /// + /// The model to generate the completion. + /// + [JsonPropertyName("model")] + [JsonRequired] + public required string Model { get; init; } + + /// + /// Usage statistics for the completion request. + /// + [JsonPropertyName("usage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CompletionUsage? Usage { get; init; } + + /// + /// The service tier used for processing the request. This field is only included if the service_tier parameter is specified in the request. + /// + [JsonPropertyName("service_tier")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ServiceTier { get; init; } + + /// + /// This fingerprint represents the backend configuration that the model runs with. + /// Can be used in conjunction with the seed request parameter to understand when backend changes have been made that might impact determinism. + /// + [JsonPropertyName("system_fingerprint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SystemFingerprint { get; init; } +} + +internal sealed record ChatCompletionChoiceChunk +{ + /// + /// The index of the choice in the list of choices. + /// + [JsonPropertyName("index")] + public required int Index { get; init; } + + /// + /// The reason the model stopped generating tokens. + /// This will be stop if the model hit a natural stop point or a provided stop sequence, length if the maximum number of tokens specified in the request was reached, + /// content_filter if content was omitted due to a flag from our content filters, tool_calls if the model called a tool, or function_call (deprecated) if the model called a function. + /// + [JsonPropertyName("finish_reason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FinishReason { get; init; } + + [JsonPropertyName("delta")] + public required ChatCompletionDelta Delta { get; init; } +} + +internal sealed record ChatCompletionDelta +{ + /// + /// The contents of the chunk message. + /// + [JsonPropertyName("content")] + public string? Content { get; init; } + + /// + /// The refusal message generated by the model. + /// + [JsonPropertyName("refusal")] + public string? Refusal { get; init; } + + /// + /// The role of the author of this message. + /// + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// Deprecated and replaced by tool_calls. The name and arguments of a function that should be called, as generated by the model. + /// + [JsonPropertyName("function_call")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChoiceMessageFunctionCall? FunctionCall { get; set; } + + [JsonPropertyName("tool_calls")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? ToolCalls { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs new file mode 100644 index 0000000000..3e9483c616 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ChatCompletionRequestMessage.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents a message in a chat completion request. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "role", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] +[JsonDerivedType(typeof(DeveloperMessage), "developer")] +[JsonDerivedType(typeof(SystemMessage), "system")] +[JsonDerivedType(typeof(UserMessage), "user")] +[JsonDerivedType(typeof(AssistantMessage), "assistant")] +[JsonDerivedType(typeof(ToolMessage), "tool")] +[JsonDerivedType(typeof(FunctionMessage), "function")] +internal abstract record ChatCompletionRequestMessage +{ + /// + /// The role of the content. + /// + [JsonIgnore] + public abstract string Role { get; } + + /// + /// The contents of the message. + /// + [JsonPropertyName("content")] + public required MessageContent Content { get; init; } + + /// + /// Converts to a . + /// + /// A representing the message. + /// Thrown when the content is neither text nor AI contents. + public virtual ChatMessage ToChatMessage() + { + if (this.Content.IsText) + { + return new(ChatRole.User, this.Content.Text); + } + else if (this.Content.IsContents) + { + var aiContents = this.Content.Contents.Select(MessageContentPartConverter.ToAIContent).Where(c => c is not null).ToList(); + return new ChatMessage(ChatRole.User, aiContents!); + } + + throw new InvalidOperationException("MessageContent has no value"); + } +} + +/// +/// A developer message in a chat completion request. +/// Developer messages are used to provide instructions to the model at the system level. +/// +internal sealed record DeveloperMessage : ChatCompletionRequestMessage +{ + /// + [JsonIgnore] + public override string Role => "developer"; + + /// + /// An optional name for the participant. + /// Provides the model information to differentiate between participants of the same role. + /// + [JsonPropertyName("name")] + public string? Name { get; init; } +} + +/// +/// A system message in a chat completion request. +/// System messages provide high-level instructions for the conversation. +/// +internal sealed record SystemMessage : ChatCompletionRequestMessage +{ + /// + [JsonIgnore] + public override string Role => "system"; + + /// + /// An optional name for the participant. + /// Provides the model information to differentiate between participants of the same role. + /// + [JsonPropertyName("name")] + public string? Name { get; init; } +} + +/// +/// A user message in a chat completion request. +/// User messages represent input from the end user. +/// +internal sealed record UserMessage : ChatCompletionRequestMessage +{ + /// + [JsonIgnore] + public override string Role => "user"; + + /// + /// An optional name for the participant. + /// Provides the model information to differentiate between participants of the same role. + /// + [JsonPropertyName("name")] + public string? Name { get; init; } +} + +/// +/// An assistant message in a chat completion request. +/// Assistant messages represent previous responses from the model, used in multi-turn conversations. +/// +internal sealed record AssistantMessage : ChatCompletionRequestMessage +{ + /// + [JsonIgnore] + public override string Role => "assistant"; + + /// + /// An optional name for the participant. + /// Provides the model information to differentiate between participants of the same role. + /// + [JsonPropertyName("name")] + public string? Name { get; init; } +} + +/// +/// A tool message in a chat completion request. +/// Tool messages contain the result of a tool call made by the assistant. +/// +internal sealed record ToolMessage : ChatCompletionRequestMessage +{ + /// + [JsonIgnore] + public override string Role => "tool"; + + /// + /// Tool call that this message is responding to. + /// + [JsonPropertyName("tool_call_id")] + public required string ToolCallId { get; set; } +} + +/// +/// Deprecated. A function message in a chat completion request. +/// Function messages have been replaced by tool messages. +/// +internal sealed record FunctionMessage : ChatCompletionRequestMessage +{ + /// + [JsonIgnore] + public override string Role => "function"; + + /// + /// The name of the function to call. + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// Converts to a . + /// + /// A representing the message. + /// Thrown when the content is not text. + public override ChatMessage ToChatMessage() + { + if (this.Content.IsText) + { + return new(ChatRole.User, this.Content.Text); + } + + throw new InvalidOperationException("FunctionMessage Content must be text"); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CompletionUsage.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CompletionUsage.cs new file mode 100644 index 0000000000..3e7632bf6e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CompletionUsage.cs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents usage statistics for a chat completion request. +/// +internal sealed record CompletionUsage +{ + public static CompletionUsage Zero { get; } = new() + { + CompletionTokens = 0, + PromptTokens = 0, + TotalTokens = 0, + CompletionTokensDetails = new() + { + AcceptedPredictionTokens = 0, + AudioTokens = 0, + ReasoningTokens = 0, + RejectedPredictionTokens = 0 + }, + PromptTokensDetails = new() + { + AudioTokens = 0, + CachedTokens = 0 + }, + }; + + /// + /// Number of tokens in the generated completion. + /// + [JsonPropertyName("completion_tokens")] + public int? CompletionTokens { get; set; } + + /// + /// Number of tokens in the prompt. + /// + [JsonPropertyName("prompt_tokens")] + public int? PromptTokens { get; set; } + + /// + /// Total number of tokens used in the request (prompt + completion). + /// + [JsonPropertyName("total_tokens")] + public int? TotalTokens { get; set; } + + /// + /// Breakdown of tokens used in the generated completion. + /// + [JsonPropertyName("completion_tokens_details")] + public required CompletionTokensDetails CompletionTokensDetails { get; set; } + + /// + /// Breakdown of tokens used in the prompt. + /// + [JsonPropertyName("prompt_tokens_details")] + public required PromptTokensDetails PromptTokensDetails { get; set; } + + public static CompletionUsage operator +(CompletionUsage left, CompletionUsage right) => new() + { + CompletionTokens = left.CompletionTokens + right.CompletionTokens, + PromptTokens = left.PromptTokens + right.PromptTokens, + TotalTokens = left.TotalTokens + right.TotalTokens, + CompletionTokensDetails = left.CompletionTokensDetails + right.CompletionTokensDetails, + PromptTokensDetails = left.PromptTokensDetails + right.PromptTokensDetails + }; +} + +/// +/// Breakdown of tokens used in a completion. +/// +internal sealed record CompletionTokensDetails +{ + /// + /// When using Predicted Outputs, the number of tokens in the prediction that appeared in the completion. + /// + [JsonPropertyName("accepted_prediction_tokens")] + public int AcceptedPredictionTokens { get; set; } + + /// + /// Audio input tokens generated by the model. + /// + [JsonPropertyName("audio_tokens")] + public int AudioTokens { get; set; } + + /// + /// Tokens generated by the model for reasoning. + /// + [JsonPropertyName("reasoning_tokens")] + public int ReasoningTokens { get; set; } + + /// + /// When using Predicted Outputs, the number of tokens in the prediction that did not appear in the completion. + /// However, like reasoning tokens, these tokens are still counted in the total completion tokens for purposes of billing, + /// output, and context window limits. + /// + [JsonPropertyName("rejected_prediction_tokens")] + public int RejectedPredictionTokens { get; set; } + + public static CompletionTokensDetails operator +(CompletionTokensDetails left, CompletionTokensDetails right) => new() + { + AcceptedPredictionTokens = left.AcceptedPredictionTokens + right.AcceptedPredictionTokens, + AudioTokens = left.AudioTokens + right.AudioTokens, + ReasoningTokens = left.ReasoningTokens + right.ReasoningTokens, + RejectedPredictionTokens = left.RejectedPredictionTokens + right.RejectedPredictionTokens + }; +} + +/// +/// Breakdown of tokens used in the prompt. +/// +internal sealed record PromptTokensDetails +{ + /// + /// Audio input tokens present in the prompt. + /// + [JsonPropertyName("audio_tokens")] + public int AudioTokens { get; set; } + + /// + /// Cached tokens present in the prompt. + /// + [JsonPropertyName("cached_tokens")] + public int CachedTokens { get; set; } + + public static PromptTokensDetails operator +(PromptTokensDetails left, PromptTokensDetails right) => new() + { + AudioTokens = left.AudioTokens + right.AudioTokens, + CachedTokens = left.CachedTokens + right.CachedTokens + }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CreateChatCompletion.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CreateChatCompletion.cs new file mode 100644 index 0000000000..2bcf509966 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/CreateChatCompletion.cs @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Request to create a chat completion. +/// +internal sealed record CreateChatCompletion +{ + /// + /// A list of messages comprising the conversation so far. + /// + [JsonPropertyName("messages")] + [JsonRequired] + public required IList Messages { get; set; } + + /// + /// Model ID used to generate the response, like `gpt-4o` or `o3`. + /// + [JsonPropertyName("model")] + [JsonRequired] + public required string Model { get; set; } + + /// + /// Parameters for audio output. Required when audio output is requested with modalities: ["audio"]. + /// + [JsonPropertyName("audio")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Audio { get; set; } + + /// + /// Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far. + /// + [JsonPropertyName("frequency_penalty")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? FrequencyPenalty { get; set; } + + /// + /// Deprecated in favor of tool_choice. Controls which (if any) function is called by the model. + /// + [JsonPropertyName("function_call")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [Obsolete("Deprecated in favor of ToolChoice.")] + public object? FunctionCall { get; set; } + + /// + /// Deprecated in favor of tools. A list of functions the model may generate JSON inputs for. + /// + [JsonPropertyName("functions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [Obsolete("Deprecated in favor of Tools.")] + public IList? Functions { get; set; } + + /// + /// Modify the likelihood of specified tokens appearing in the completion. + /// + [JsonPropertyName("logit_bias")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? LogitBias { get; set; } + + /// + /// Whether to return log probabilities of the output tokens or not. + /// + [JsonPropertyName("logprobs")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Logprobs { get; set; } + + /// + /// An upper bound for the number of tokens that can be generated for a completion, including visible output tokens and reasoning tokens. + /// + [JsonPropertyName("max_completion_tokens")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? MaxCompletionTokens { get; set; } + + /// + /// The maximum number of tokens that can be generated in the chat completion. (Deprecated in favor of max_completion_tokens) + /// + [JsonPropertyName("max_tokens")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [Obsolete("Use MaxCompletionTokens instead. This property is deprecated and not compatible with o-series models.")] + public int? MaxTokens { get; set; } + + /// + /// Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional + /// information about the object in a structured format, and querying for objects via API or the dashboard. + /// Keys are strings with a maximum length of 64 characters. Values are strings with a maximum length of 512 characters. + /// + [JsonPropertyName("metadata")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Metadata { get; set; } + + /// + /// Types of content modalities the model can output. Can include "text" and/or "audio". + /// + [JsonPropertyName("modalities")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Modalities { get; set; } + + /// + /// How many chat completion choices to generate for each input message. + /// + [JsonPropertyName("n")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? N { get; set; } + + /// + /// Whether to enable parallel function calling during tool use. + /// + [JsonPropertyName("parallel_tool_calls")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? ParallelToolCalls { get; set; } + + /// + /// Configuration for a Predicted Output, which can greatly improve response times when large parts of the model response are known ahead of time. + /// + [JsonPropertyName("prediction")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Prediction { get; set; } + + /// + /// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far. + /// + [JsonPropertyName("presence_penalty")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? PresencePenalty { get; set; } + + /// + /// Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. + /// + [JsonPropertyName("prompt_cache_key")] + public string? PromptCacheKey { get; init; } + + /// + /// The reasoning effort level for o-series models. Can be "low", "medium", or "high". + /// + [JsonPropertyName("reasoning_effort")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ReasoningEffort { get; set; } + + /// + /// An object specifying the format that the model must output. + /// + [JsonPropertyName("response_format")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ResponseFormat? ResponseFormat { get; set; } + + /// + /// A stable identifier used to help detect users of your application that may be violating OpenAI's usage policies. + /// The IDs should be a string that uniquely identifies each user. We recommend hashing their username or email address, + /// in order to avoid sending us any identifying information. + /// + [JsonPropertyName("safety_identifier")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SafetyIdentifier { get; set; } + + /// + /// If specified, the system will make a best effort to sample deterministically. + /// + [JsonPropertyName("seed")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Seed { get; set; } + + /// + /// Specifies the processing type used for serving the request. + /// If set to 'auto', the request will be processed with the service tier configured in the Project settings. + /// If set to 'default', the request will be processed with standard pricing and performance. + /// If set to 'flex' or 'priority', the request will be processed with the corresponding service tier. + /// Defaults to 'auto'. + /// + [JsonPropertyName("service_tier")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ServiceTier { get; set; } + + /// + /// Up to 4 sequences where the API will stop generating further tokens. + /// + [JsonPropertyName("stop")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public StopSequences? Stop { get; set; } + + /// + /// Whether or not to store the output of this chat completion request for use in model distillation or evals products. + /// + [JsonPropertyName("store")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Store { get; set; } + + /// + /// If set to true, the model response data will be streamed to the client using server-sent events. + /// + [JsonPropertyName("stream")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Stream { get; set; } + + /// + /// Options for streaming response. Only set this when you set stream: true. + /// + [JsonPropertyName("stream_options")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? StreamOptions { get; set; } + + /// + /// What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, + /// while lower values like 0.2 will make it more focused and deterministic. + /// We generally recommend altering this or top_p but not both. Defaults to 1. + /// + [JsonPropertyName("temperature")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? Temperature { get; set; } + + /// + /// Controls which (if any) tool is called by the model. + /// + [JsonPropertyName("tool_choice")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolChoice? ToolChoice { get; set; } + + /// + /// A list of tools the model may call. Can include custom tools or function tools. + /// + [JsonPropertyName("tools")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Tools { get; set; } + + /// + /// An integer between 0 and 20 specifying the number of most likely tokens to return at each token position. + /// + [JsonPropertyName("top_logprobs")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? TopLogprobs { get; set; } + + /// + /// An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of + /// the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. + /// We generally recommend altering this or temperature but not both. + /// + [JsonPropertyName("top_p")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? TopP { get; set; } + + /// + /// Level of detail in the model's output. Can be "standard" or "verbose". + /// + [JsonPropertyName("verbosity")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Verbosity { get; set; } = "medium"; + + /// + /// Web search tool configuration for searching the web for relevant results. + /// + [JsonPropertyName("web_search_options")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? WebSearchOptions { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContent.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContent.cs new file mode 100644 index 0000000000..001d4cc4ad --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContent.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Content which is a part of . +/// Can be either a string, or a list of content parts +/// +[JsonConverter(typeof(MessageContentJsonConverter))] +internal sealed record MessageContent : IEquatable +{ + private MessageContent(string text) + { + this.Text = text ?? throw new ArgumentNullException(nameof(text)); + this.Contents = null; + } + + private MessageContent(IReadOnlyList contents) + { + this.Contents = contents ?? throw new ArgumentNullException(nameof(contents)); + this.Text = null; + } + + /// + /// Creates an MessageContent from a text string. + /// + public static MessageContent FromText(string text) => new(text); + + /// + /// Creates an MessageContent from a list of MessageContentPart items. + /// + public static MessageContent FromContents(IReadOnlyList contents) => new(contents); + + /// + /// Creates an MessageContent from a list of MessageContentPart items. + /// + public static MessageContent FromContents(params MessageContentPart[] contents) => new(contents); + + /// + /// Implicit conversion from string to MessageContent. + /// + public static implicit operator MessageContent(string text) => FromText(text); + + /// + /// Implicit conversion from List to MessageContent. + /// + public static implicit operator MessageContent(List contents) => FromContents(contents); + + /// + /// Gets whether this content is text. + /// + [MemberNotNullWhen(true, nameof(Text))] + public bool IsText => this.Text is not null; + + /// + /// Gets whether this content is a list of ItemContent items. + /// + [MemberNotNullWhen(true, nameof(Contents))] + public bool IsContents => this.Contents is not null; + + /// + /// Gets the text value, or null if this is not text content. + /// + public string? Text { get; } + + /// + /// Gets the ItemContent items, or null if this is not a content list. + /// + public IReadOnlyList? Contents { get; } + + /// + public bool Equals(MessageContent? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + // Both text + if (this.Text is not null && other.Text is not null) + { + return this.Text == other.Text; + } + + // Both contents + if (this.Contents is not null + && other.Contents is not null + && this.Contents.Count == other.Contents.Count) + { + return this.Contents.SequenceEqual(other.Contents); + } + + // One is text, one is contents - not equal + return false; + } + + /// + public override int GetHashCode() + { + if (this.Text is not null) + { + return this.Text.GetHashCode(); + } + + if (this.Contents is not null) + { + return this.Contents.Count > 0 ? this.Contents[0].GetHashCode() : 0; + } + + return 0; + } +} + +/// +/// JSON converter for . +/// +internal sealed class MessageContentJsonConverter : JsonConverter +{ + public override MessageContent? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Check if it's a string + if (reader.TokenType == JsonTokenType.String) + { + var text = reader.GetString(); + return text is not null ? MessageContent.FromText(text) : null; + } + + // Check if it's an array of ItemContent + if (reader.TokenType == JsonTokenType.StartArray) + { + var contents = JsonSerializer.Deserialize(ref reader, ChatCompletionsJsonContext.Default.IReadOnlyListMessageContentPart); + return contents?.Count > 0 + ? MessageContent.FromContents(contents) + : MessageContent.FromText(string.Empty); + } + + throw new JsonException($"Unexpected token type for MessageContent: {reader.TokenType}"); + } + + public override void Write(Utf8JsonWriter writer, MessageContent value, JsonSerializerOptions options) + { + if (value.IsText) + { + writer.WriteStringValue(value.Text); + } + else if (value.IsContents) + { + JsonSerializer.Serialize(writer, value.Contents, ChatCompletionsJsonContext.Default.IReadOnlyListMessageContentPart); + } + else + { + throw new JsonException("MessageContent has no value"); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContentPart.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContentPart.cs new file mode 100644 index 0000000000..a626190d8e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/MessageContentPart.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents a part of message content in a chat completion request. +/// Message content can be text, images, audio, or files. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] +[JsonDerivedType(typeof(TextContentPart), "text")] +[JsonDerivedType(typeof(ImageContentPart), "image_url")] +[JsonDerivedType(typeof(AudioContentPart), "input_audio")] +[JsonDerivedType(typeof(FileContentPart), "file")] +internal abstract record MessageContentPart +{ + /// + /// The type of the content. + /// + [JsonIgnore] + public abstract string Type { get; } +} + +/// +/// A text content part in a message. +/// +internal sealed record TextContentPart : MessageContentPart +{ + /// + [JsonIgnore] + public override string Type => "text"; + + /// + /// The text content. + /// + [JsonPropertyName("text")] + public required string Text { get; set; } +} + +/// +/// An image content part in a message. +/// +internal sealed record ImageContentPart : MessageContentPart +{ + /// + [JsonIgnore] + public override string Type => "image_url"; + + /// + /// Details about the image URL or base64-encoded image data. + /// + [JsonPropertyName("image_url")] + public required ImageUrl ImageUrl { get; set; } + + /// + /// Gets the URL or base64-encoded data of the image. + /// + [JsonIgnore] + public string UrlOrData => this.ImageUrl.Url; + + /// + /// Gets the URL of the image. + /// + [JsonIgnore] + public Uri Url => new(this.ImageUrl.Url); +} + +/// +/// Details about an image for vision-enabled models. +/// +internal sealed record ImageUrl +{ + /// + /// Either a URL of the image or the base64 encoded image data + /// + [JsonPropertyName("url")] + public required string Url { get; set; } + + /// + /// Specifies the detail level of the image + /// + [JsonPropertyName("detail")] + public string? Detail { get; set; } +} + +/// +/// An audio content part in a message. +/// +internal sealed record AudioContentPart : MessageContentPart +{ + /// + [JsonIgnore] + public override string Type => "input_audio"; + + /// + /// The input audio data. + /// + [JsonPropertyName("input_audio")] + public required InputAudio InputAudio { get; set; } +} + +/// +/// Input audio data for audio-enabled models. +/// +internal sealed record InputAudio +{ + /// + /// Base64 encoded audio data. + /// + [JsonPropertyName("data")] + public required string Data { get; set; } + + /// + /// The format of the encoded audio data. Currently supports "wav" and "mp3". + /// + [JsonPropertyName("format")] + public required string Format { get; set; } +} + +/// +/// A file content part in a message. +/// +internal sealed record FileContentPart : MessageContentPart +{ + /// + [JsonIgnore] + public override string Type => "file"; + + /// + /// The input file data. + /// + [JsonPropertyName("file")] + public required InputFile File { get; set; } +} + +/// +/// Input file data for file-enabled models. +/// +internal sealed record InputFile +{ + /// + /// The base64 encoded file data, used when passing the file to the model as a string. + /// + [JsonPropertyName("file_data")] + public string? FileData { get; set; } + + /// + /// The ID of an uploaded file to use as input. + /// + [JsonPropertyName("file_id")] + public string? FileId { get; set; } + + /// + /// The name of the file, used when passing the file to the model as a string. + /// + [JsonPropertyName("filename")] + public string? Filename { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ResponseFormat.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ResponseFormat.cs new file mode 100644 index 0000000000..74509d94b5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ResponseFormat.cs @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Specifies the format that the model must output. +/// +[JsonConverter(typeof(ResponseFormatConverter))] +internal sealed record ResponseFormat : IEquatable +{ + private ResponseFormat(TextResponseFormat text) + { + this.Text = text ?? throw new ArgumentNullException(nameof(text)); + this.JsonSchema = null; + this.JsonObject = null; + } + + private ResponseFormat(JsonSchemaResponseFormat jsonSchema) + { + this.JsonSchema = jsonSchema ?? throw new ArgumentNullException(nameof(jsonSchema)); + this.Text = null; + this.JsonObject = null; + } + + private ResponseFormat(JsonObjectResponseFormat jsonObject) + { + this.JsonObject = jsonObject ?? throw new ArgumentNullException(nameof(jsonObject)); + this.Text = null; + this.JsonSchema = null; + } + + /// + /// Creates a ResponseFormat for text output (default). + /// + public static ResponseFormat FromText() => new(new TextResponseFormat()); + + /// + /// Creates a ResponseFormat for JSON Schema output with Structured Outputs. + /// + public static ResponseFormat FromJsonSchema(JsonSchemaResponseFormat jsonSchema) => new(jsonSchema); + + /// + /// Creates a ResponseFormat for JSON object output (older JSON mode). + /// + public static ResponseFormat FromJsonObject() => new(new JsonObjectResponseFormat()); + + /// + /// Gets whether this is a text response format. + /// + [MemberNotNullWhen(true, nameof(Text))] + public bool IsText => this.Text is not null; + + /// + /// Gets whether this is a JSON schema response format. + /// + [MemberNotNullWhen(true, nameof(JsonSchema))] + public bool IsJsonSchema => this.JsonSchema is not null; + + /// + /// Gets whether this is a JSON object response format. + /// + [MemberNotNullWhen(true, nameof(JsonObject))] + public bool IsJsonObject => this.JsonObject is not null; + + /// + /// Gets the text response format, or null if this is not a text format. + /// + public TextResponseFormat? Text { get; } + + /// + /// Gets the JSON schema response format, or null if this is not a JSON schema format. + /// + public JsonSchemaResponseFormat? JsonSchema { get; } + + /// + /// Gets the JSON object response format, or null if this is not a JSON object format. + /// + public JsonObjectResponseFormat? JsonObject { get; } + + /// + public bool Equals(ResponseFormat? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + if (this.Text is not null && other.Text is not null) + { + return this.Text.Equals(other.Text); + } + + if (this.JsonSchema is not null && other.JsonSchema is not null) + { + return this.JsonSchema.Equals(other.JsonSchema); + } + + if (this.JsonObject is not null && other.JsonObject is not null) + { + return this.JsonObject.Equals(other.JsonObject); + } + + return false; + } + + /// + public override int GetHashCode() + { + if (this.Text is not null) + { + return this.Text.GetHashCode(); + } + + if (this.JsonSchema is not null) + { + return this.JsonSchema.GetHashCode(); + } + + if (this.JsonObject is not null) + { + return this.JsonObject.GetHashCode(); + } + + return 0; + } +} + +/// +/// Text response format. Default response format used to generate text responses. +/// +internal sealed record TextResponseFormat +{ + /// + /// The type of response format. Always "text". + /// + [JsonPropertyName("type")] + public string Type => "text"; +} + +/// +/// JSON Schema response format. Used to generate structured JSON responses with Structured Outputs. +/// +internal sealed record JsonSchemaResponseFormat +{ + /// + /// The type of response format. Always "json_schema". + /// + [JsonPropertyName("type")] + public string Type => "json_schema"; + + /// + /// Structured Outputs configuration options, including a JSON Schema. + /// + [JsonPropertyName("json_schema")] + [JsonRequired] + public required JsonSchemaConfiguration JsonSchema { get; init; } +} + +/// +/// Configuration for JSON Schema Structured Outputs. +/// +internal sealed record JsonSchemaConfiguration +{ + /// + /// The name of the schema. + /// + [JsonPropertyName("name")] + [JsonRequired] + public required string Name { get; init; } + + /// + /// A description of the schema. + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// + /// The JSON Schema definition. + /// + [JsonPropertyName("schema")] + [JsonRequired] + public required JsonElement Schema { get; init; } + + /// + /// Whether to enable strict schema adherence. + /// + [JsonPropertyName("strict")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Strict { get; init; } +} + +/// +/// JSON object response format. An older method of generating JSON responses. +/// Using json_schema is recommended for models that support it. +/// +internal sealed record JsonObjectResponseFormat +{ + /// + /// The type of response format. Always "json_object". + /// + [JsonPropertyName("type")] + public string Type => "json_object"; +} + +/// +/// JSON converter for that handles different response format types. +/// +internal sealed class ResponseFormatConverter : JsonConverter +{ + /// + public override ResponseFormat? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.StartObject) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + if (root.TryGetProperty("type", out var typeProperty)) + { + var type = typeProperty.GetString(); + return type switch + { + "text" => ResponseFormat.FromText(), + + "json_schema" => ResponseFormat.FromJsonSchema( + JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.JsonSchemaResponseFormat)!), + + "json_object" => ResponseFormat.FromJsonObject(), + + _ => throw new JsonException($"Unknown response format type: {type}") + }; + } + + throw new JsonException("Response format object must have a 'type' property."); + } + + throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing ResponseFormat."); + } + + /// + public override void Write(Utf8JsonWriter writer, ResponseFormat? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + if (value.IsText) + { + JsonSerializer.Serialize(writer, value.Text, ChatCompletionsJsonContext.Default.TextResponseFormat); + } + else if (value.IsJsonSchema) + { + JsonSerializer.Serialize(writer, value.JsonSchema, ChatCompletionsJsonContext.Default.JsonSchemaResponseFormat); + } + else if (value.IsJsonObject) + { + JsonSerializer.Serialize(writer, value.JsonObject, ChatCompletionsJsonContext.Default.JsonObjectResponseFormat); + } + else + { + writer.WriteNullValue(); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/StopSequences.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/StopSequences.cs new file mode 100644 index 0000000000..bed3b2a320 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/StopSequences.cs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents stop sequences for chat completion generation. +/// Up to 4 sequences where the API will stop generating further tokens. +/// +[JsonConverter(typeof(StopSequencesConverter))] +internal sealed record StopSequences : IEquatable +{ + private StopSequences(string singleSequence) + { + this.SingleSequence = singleSequence ?? throw new ArgumentNullException(nameof(singleSequence)); + this.Sequences = null; + } + + private StopSequences(IList sequences) + { + if (sequences is null || sequences.Count == 0) + { + throw new ArgumentException("Sequences cannot be null or empty.", nameof(sequences)); + } + + if (sequences.Count > 4) + { + throw new ArgumentException("Maximum of 4 stop sequences are allowed.", nameof(sequences)); + } + + this.Sequences = sequences; + this.SingleSequence = null; + } + + /// + /// Creates a StopSequences from a single stop sequence string. + /// + public static StopSequences FromString(string sequence) => new(sequence); + + /// + /// Creates a StopSequences from a list of stop sequences. + /// + public static StopSequences FromSequences(IList sequences) => new(sequences); + + /// + /// Implicit conversion from string to StopSequences. + /// + public static implicit operator StopSequences(string sequence) => FromString(sequence); + + /// + /// Implicit conversion from string array to StopSequences. + /// + public static implicit operator StopSequences(string[] sequences) => FromSequences(sequences); + + /// + /// Implicit conversion from List to StopSequences. + /// + public static implicit operator StopSequences(List sequences) => FromSequences(sequences); + + /// + /// Gets whether this is a single stop sequence. + /// + [MemberNotNullWhen(true, nameof(SingleSequence))] + public bool IsSingleSequence => this.SingleSequence is not null; + + /// + /// Gets whether this contains multiple stop sequences. + /// + [MemberNotNullWhen(true, nameof(Sequences))] + public bool IsSequences => this.Sequences is not null; + + /// + /// Gets the single stop sequence, or null if this contains multiple sequences. + /// + public string? SingleSequence { get; } + + /// + /// Gets the list of stop sequences, or null if this is a single sequence. + /// + public IList? Sequences { get; } + + public IList SequenceList => + this.IsSingleSequence ? [this.SingleSequence] : + this.IsSequences ? this.Sequences : []; + + /// + public bool Equals(StopSequences? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + // Both single sequences + if (this.SingleSequence is not null && other.SingleSequence is not null) + { + return this.SingleSequence == other.SingleSequence; + } + + // Both sequences + if (this.Sequences is not null && other.Sequences is not null) + { + return this.Sequences.SequenceEqual(other.Sequences); + } + + // One is single, one is sequences - not equal + return false; + } + + /// + public override int GetHashCode() + { + if (this.SingleSequence is not null) + { + return this.SingleSequence.GetHashCode(); + } + + if (this.Sequences is not null) + { + return this.Sequences.Count > 0 ? this.Sequences[0].GetHashCode() : 0; + } + + return 0; + } +} + +/// +/// JSON converter for that handles string, array, and null representations. +/// +internal sealed class StopSequencesConverter : JsonConverter +{ + /// + public override StopSequences? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Handle null + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + // Handle single string + if (reader.TokenType == JsonTokenType.String) + { + string? sequence = reader.GetString(); + return sequence is not null ? StopSequences.FromString(sequence) : null; + } + + // Handle array of strings + if (reader.TokenType == JsonTokenType.StartArray) + { + var sequences = JsonSerializer.Deserialize(ref reader, ChatCompletionsJsonContext.Default.IListString); + return sequences?.Count > 0 + ? StopSequences.FromSequences(sequences) + : StopSequences.FromString(string.Empty); + } + + throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing StopSequences. Expected String, StartArray, or Null."); + } + + /// + public override void Write(Utf8JsonWriter writer, StopSequences? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + if (value.IsSingleSequence) + { + writer.WriteStringValue(value.SingleSequence); + } + else if (value.IsSequences) + { + JsonSerializer.Serialize(writer, value.Sequences, ChatCompletionsJsonContext.Default.IReadOnlyListMessageContentPart); + } + else + { + writer.WriteNullValue(); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs new file mode 100644 index 0000000000..470f7d15b0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/Tool.cs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Represents a tool that the model may call. Can be either a function tool or a custom tool. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(FunctionTool), "function")] +[JsonDerivedType(typeof(CustomTool), "custom")] +internal abstract record Tool +{ + /// + /// The type of the tool. + /// + [JsonPropertyName("type")] + public abstract string Type { get; } +} + +/// +/// A function tool that can be used to generate a response. +/// +internal sealed record FunctionTool : Tool +{ + /// + /// The type of the tool. Always "function". + /// + [JsonPropertyName("type")] + public override string Type => "function"; + + /// + /// The function definition. + /// + [JsonPropertyName("function")] + [JsonRequired] + public required FunctionDefinition Function { get; init; } +} + +/// +/// Definition of a function that can be called by the model. +/// +internal sealed record FunctionDefinition +{ + /// + /// The name of the function to be called. + /// Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64. + /// + [JsonPropertyName("name")] + [JsonRequired] + public required string Name { get; init; } + + /// + /// A description of what the function does, used by the model to choose when and how to call the function. + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// + /// The parameters the function accepts, described as a JSON Schema object. + /// Omitting parameters defines a function with an empty parameter list. + /// + [JsonPropertyName("parameters")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Parameters { get; init; } + + /// + /// Whether to enable strict schema adherence when generating the function call. + /// If set to true, the model will follow the exact schema defined in the parameters field. + /// Only a subset of JSON Schema is supported when strict is true. + /// Defaults to false. + /// + [JsonPropertyName("strict")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Strict { get; init; } +} + +/// +/// A custom tool that processes input using a specified format. +/// +internal sealed record CustomTool : Tool +{ + /// + /// The type of the tool. Always "custom". + /// + [JsonPropertyName("type")] + public override string Type => "custom"; + + /// + /// Properties of the custom tool. + /// + [JsonPropertyName("custom")] + [JsonRequired] + public required CustomToolProperties Custom { get; init; } +} + +/// +/// A wrapper for MEAI +/// +internal sealed class CustomAITool : AITool +{ + public CustomAITool(string name, string? description, IReadOnlyDictionary? additionalProperties) + : base() + { + this.Name = name; + this.Description = description ?? string.Empty; + this.AdditionalProperties = additionalProperties ?? new Dictionary(); + } + + public override string Name { get; } + public override string Description { get; } + public override IReadOnlyDictionary AdditionalProperties { get; } +} + +/// +/// Properties of a custom tool. +/// +internal sealed record CustomToolProperties +{ + /// + /// The name of the custom tool, used to identify it in tool calls. + /// + [JsonPropertyName("name")] + [JsonRequired] + public required string Name { get; init; } + + /// + /// Optional description of the custom tool, used to provide more context. + /// + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + /// + /// The input format for the custom tool. Default is unconstrained text. + /// + [JsonPropertyName("format")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public CustomToolFormat? Format { get; init; } +} + +/// +/// The input format for a custom tool. +/// +internal sealed record CustomToolFormat +{ + /// + /// The type of format. Can be various schema types. + /// + [JsonPropertyName("type")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Type { get; init; } + + /// + /// Additional format properties (schema definition). + /// + [JsonExtensionData] + public Dictionary? AdditionalProperties { get; init; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ToolChoice.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ToolChoice.cs new file mode 100644 index 0000000000..a5dcc3ff25 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Models/ToolChoice.cs @@ -0,0 +1,384 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; + +/// +/// Controls which (if any) tool is called by the model. +/// +[JsonConverter(typeof(ToolChoiceConverter))] +internal sealed record ToolChoice : IEquatable +{ + private ToolChoice(string mode) + { + this.Mode = mode ?? throw new ArgumentNullException(nameof(mode)); + this.AllowedTools = null; + this.FunctionTool = null; + this.CustomTool = null; + } + + private ToolChoice(AllowedToolsChoice allowedTools) + { + this.AllowedTools = allowedTools ?? throw new ArgumentNullException(nameof(allowedTools)); + this.Mode = null; + this.FunctionTool = null; + this.CustomTool = null; + } + + private ToolChoice(FunctionToolChoice functionTool) + { + this.FunctionTool = functionTool ?? throw new ArgumentNullException(nameof(functionTool)); + this.Mode = null; + this.AllowedTools = null; + this.CustomTool = null; + } + + private ToolChoice(CustomToolChoice customTool) + { + this.CustomTool = customTool ?? throw new ArgumentNullException(nameof(customTool)); + this.Mode = null; + this.AllowedTools = null; + this.FunctionTool = null; + } + + /// + /// Creates a ToolChoice from a mode string ("none", "auto", or "required"). + /// + public static ToolChoice FromMode(string mode) => new(mode); + + /// + /// Creates a ToolChoice that constrains tools to a pre-defined set. + /// + public static ToolChoice FromAllowedTools(AllowedToolsChoice allowedTools) => new(allowedTools); + + /// + /// Creates a ToolChoice that forces the model to call a specific function. + /// + public static ToolChoice FromFunction(FunctionToolChoice functionTool) => new(functionTool); + + /// + /// Creates a ToolChoice that forces the model to call a specific custom tool. + /// + public static ToolChoice FromCustom(CustomToolChoice customTool) => new(customTool); + + /// + /// Implicit conversion from string to ToolChoice. + /// + public static implicit operator ToolChoice(string mode) => FromMode(mode); + + /// + /// Gets whether this is a mode string. + /// + [MemberNotNullWhen(true, nameof(Mode))] + public bool IsMode => this.Mode is not null; + + /// + /// Gets whether this is an allowed tools configuration. + /// + [MemberNotNullWhen(true, nameof(AllowedTools))] + public bool IsAllowedTools => this.AllowedTools is not null; + + /// + /// Gets whether this is a function tool choice. + /// + [MemberNotNullWhen(true, nameof(FunctionTool))] + public bool IsFunctionTool => this.FunctionTool is not null; + + /// + /// Gets whether this is a custom tool choice. + /// + [MemberNotNullWhen(true, nameof(CustomTool))] + public bool IsCustomTool => this.CustomTool is not null; + + /// + /// Gets the mode string, or null if this is not a mode. + /// + public string? Mode { get; } + + /// + /// Gets the allowed tools configuration, or null if this is not an allowed tools choice. + /// + public AllowedToolsChoice? AllowedTools { get; } + + /// + /// Gets the function tool choice, or null if this is not a function tool choice. + /// + public FunctionToolChoice? FunctionTool { get; } + + /// + /// Gets the custom tool choice, or null if this is not a custom tool choice. + /// + public CustomToolChoice? CustomTool { get; } + + /// + public bool Equals(ToolChoice? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + if (this.Mode is not null && other.Mode is not null) + { + return this.Mode == other.Mode; + } + + if (this.AllowedTools is not null && other.AllowedTools is not null) + { + return this.AllowedTools.Equals(other.AllowedTools); + } + + if (this.FunctionTool is not null && other.FunctionTool is not null) + { + return this.FunctionTool.Equals(other.FunctionTool); + } + + if (this.CustomTool is not null && other.CustomTool is not null) + { + return this.CustomTool.Equals(other.CustomTool); + } + + return false; + } + + /// + public override int GetHashCode() + { + if (this.Mode is not null) + { + return this.Mode.GetHashCode(); + } + + if (this.AllowedTools is not null) + { + return this.AllowedTools.GetHashCode(); + } + + if (this.FunctionTool is not null) + { + return this.FunctionTool.GetHashCode(); + } + + if (this.CustomTool is not null) + { + return this.CustomTool.GetHashCode(); + } + + return 0; + } +} + +/// +/// Constrains the tools available to the model to a pre-defined set. +/// +internal sealed record AllowedToolsChoice +{ + /// + /// The type of tool choice. Always "allowed_tools". + /// + [JsonPropertyName("type")] + public string Type => "allowed_tools"; + + /// + /// Constrains the tools available to the model to a pre-defined set. + /// + [JsonPropertyName("allowed_tools")] + [JsonRequired] + public required AllowedToolsConfiguration AllowedTools { get; init; } +} + +/// +/// Configuration for allowed tools. +/// +internal sealed record AllowedToolsConfiguration +{ + /// + /// Constrains the tools available to the model to a pre-defined set. + /// auto allows the model to pick from among the allowed tools and generate a message. + /// required requires the model to call one or more of the allowed tools. + /// + [JsonPropertyName("mode")] + [JsonRequired] + public required string Mode { get; init; } + + /// + /// A list of tool definitions that the model should be allowed to call. + /// + [JsonPropertyName("tools")] + [JsonRequired] + public required IList Tools { get; init; } +} + +/// +/// A tool definition in the allowed tools list. +/// +internal sealed record ToolDefinition +{ + /// + /// The type of tool (e.g., "function" or "custom"). + /// + [JsonPropertyName("type")] + [JsonRequired] + public required string Type { get; init; } + + /// + /// The function details if type is "function". + /// + [JsonPropertyName("function")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public FunctionReference? Function { get; init; } +} + +/// +/// A reference to a function by name. +/// +internal sealed record FunctionReference +{ + /// + /// The name of the function. + /// + [JsonPropertyName("name")] + [JsonRequired] + public required string Name { get; init; } +} + +/// +/// Specifies a function tool the model should use. +/// +internal sealed record FunctionToolChoice +{ + /// + /// The type of tool. Always "function". + /// + [JsonPropertyName("type")] + public string Type => "function"; + + /// + /// The function to call. + /// + [JsonPropertyName("function")] + [JsonRequired] + public required FunctionReference Function { get; init; } +} + +/// +/// Specifies a custom tool the model should use. +/// +internal sealed record CustomToolChoice +{ + /// + /// The type of tool. Always "custom". + /// + [JsonPropertyName("type")] + public string Type => "custom"; + + /// + /// The custom tool configuration. + /// + [JsonPropertyName("custom")] + [JsonRequired] + public required CustomToolObject Custom { get; init; } +} + +/// +/// A reference to a custom tool object. +/// +internal sealed record CustomToolObject +{ + /// + /// The name of the function. + /// + [JsonPropertyName("name")] + [JsonRequired] + public required string Name { get; init; } +} + +/// +/// JSON converter for that handles string and object representations. +/// +internal sealed class ToolChoiceConverter : JsonConverter +{ + /// + public override ToolChoice? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.String) + { + string? mode = reader.GetString(); + return mode is not null ? ToolChoice.FromMode(mode) : null; + } + + if (reader.TokenType == JsonTokenType.StartObject) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + if (root.TryGetProperty("type", out var typeProperty)) + { + var type = typeProperty.GetString(); + return type switch + { + "allowed_tools" => ToolChoice.FromAllowedTools( + JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.AllowedToolsChoice)!), + + "function" => ToolChoice.FromFunction( + JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.FunctionToolChoice)!), + + "custom" => ToolChoice.FromCustom( + JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.CustomToolChoice)!), + + _ => throw new JsonException($"Unknown tool choice type: {type}") + }; + } + + throw new JsonException("Tool choice object must have a 'type' property."); + } + + throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing ToolChoice."); + } + + /// + public override void Write(Utf8JsonWriter writer, ToolChoice? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + if (value.IsMode) + { + writer.WriteStringValue(value.Mode); + } + else if (value.IsAllowedTools) + { + JsonSerializer.Serialize(writer, value.AllowedTools, ChatCompletionsJsonContext.Default.AllowedToolsChoice); + } + else if (value.IsFunctionTool) + { + JsonSerializer.Serialize(writer, value.FunctionTool, ChatCompletionsJsonContext.Default.FunctionToolChoice); + } + else if (value.IsCustomTool) + { + JsonSerializer.Serialize(writer, value.CustomTool, ChatCompletionsJsonContext.Default.CustomToolChoice); + } + else + { + writer.WriteNullValue(); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Utils/ChatCompletionsOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Utils/ChatCompletionsOptionsExtensions.cs deleted file mode 100644 index 36534c637c..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Utils/ChatCompletionsOptionsExtensions.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Reflection; -using Microsoft.Shared.Diagnostics; -using OpenAI.Chat; - -namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Utils; - -[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1810:Initialize reference type static fields inline", Justification = "Specifically for accessing hidden members")] -[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "Specifically for accessing hidden members")] -internal static class ChatCompletionsOptionsExtensions -{ - private static readonly Func s_getStreamNullable; - private static readonly Func> s_getMessages; - - static ChatCompletionsOptionsExtensions() - { - // OpenAI SDK does not have a simple way to get the input as a c# object. - // However, it does parse most of the interesting fields into internal properties of `ChatCompletionsOptions` object. - - // --- Stream (internal bool? Stream { get; set; }) --- - const string StreamPropName = "Stream"; - var streamProp = typeof(ChatCompletionOptions).GetProperty(StreamPropName, BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new MissingMemberException(typeof(ChatCompletionOptions).FullName!, StreamPropName); - var streamGetter = streamProp.GetGetMethod(nonPublic: true) ?? throw new MissingMethodException($"{StreamPropName} getter not found."); - - s_getStreamNullable = streamGetter.CreateDelegate>(); - - // --- Messages (internal IList Messages { get; set; }) --- - const string InputPropName = "Messages"; - var inputProp = typeof(ChatCompletionOptions).GetProperty(InputPropName, BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new MissingMemberException(typeof(ChatCompletionOptions).FullName!, InputPropName); - var inputGetter = inputProp.GetGetMethod(nonPublic: true) - ?? throw new MissingMethodException($"{InputPropName} getter not found."); - - s_getMessages = inputGetter.CreateDelegate>>(); - } - - public static IList GetMessages(this ChatCompletionOptions options) - { - Throw.IfNull(options); - return s_getMessages(options); - } - - public static bool GetStream(this ChatCompletionOptions options) - { - Throw.IfNull(options); - return s_getStreamNullable(options) ?? false; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs index 5fe39019fc..3fcc9cad27 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs @@ -1,16 +1,15 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.ClientModel.Primitives; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Threading; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; -using Microsoft.AspNetCore.Http; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -using OpenAI.Chat; namespace Microsoft.AspNetCore.Builder; @@ -20,47 +19,54 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt /// Maps OpenAI ChatCompletions API endpoints to the specified for the given . /// /// The to add the OpenAI ChatCompletions endpoints to. - /// The name of the AI agent service registered in the dependency injection container. This name is used to resolve the instance from the keyed services. + /// The builder for to map the OpenAI ChatCompletions endpoints for. + public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder) + => MapOpenAIChatCompletions(endpoints, agentBuilder, path: null); + + /// + /// Maps OpenAI ChatCompletions API endpoints to the specified for the given . + /// + /// The to add the OpenAI ChatCompletions endpoints to. + /// The builder for to map the OpenAI ChatCompletions endpoints for. /// Custom route path for the chat completions endpoint. - public static void MapOpenAIChatCompletions( + public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path) + { + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentBuilder.Name); + return MapOpenAIChatCompletions(endpoints, agent, path); + } + + /// + /// Maps OpenAI ChatCompletions API endpoints to the specified for the given . + /// + /// The to add the OpenAI ChatCompletions endpoints to. + /// The instance to map the OpenAI ChatCompletions endpoints for. + public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, AIAgent agent) + => MapOpenAIChatCompletions(endpoints, agent, path: null); + + /// + /// Maps OpenAI ChatCompletions API endpoints to the specified for the given . + /// + /// The to add the OpenAI ChatCompletions endpoints to. + /// The instance to map the OpenAI ChatCompletions endpoints for. + /// Custom route path for the chat completions endpoint. + public static IEndpointConventionBuilder MapOpenAIChatCompletions( this IEndpointRouteBuilder endpoints, - string agentName, - [StringSyntax("Route")] string? path = null) + AIAgent agent, + [StringSyntax("Route")] string? path) { ArgumentNullException.ThrowIfNull(endpoints); - ArgumentNullException.ThrowIfNull(agentName); - if (path is null) - { - ValidateAgentName(agentName); - } + ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent.Name)); + ValidateAgentName(agent.Name); - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - - path ??= $"/{agentName}/v1/chat/completions"; - var chatCompletionsRouteGroup = endpoints.MapGroup(path); - MapChatCompletions(chatCompletionsRouteGroup, agent); - } - - private static void MapChatCompletions(IEndpointRouteBuilder routeGroup, AIAgent agent) - { + path ??= $"/{agent.Name}/v1/chat/completions"; + var group = endpoints.MapGroup(path); var endpointAgentName = agent.DisplayName; - var chatCompletionsProcessor = new AIAgentChatCompletionsProcessor(agent); - routeGroup.MapPost("/", async (HttpContext requestContext, CancellationToken cancellationToken) => - { - var requestBinary = await BinaryData.FromStreamAsync(requestContext.Request.Body, cancellationToken).ConfigureAwait(false); + group.MapPost("/", async ([FromBody] CreateChatCompletion request, CancellationToken cancellationToken) + => await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, cancellationToken).ConfigureAwait(false)) + .WithName(endpointAgentName + "/CreateChatCompletion"); - var chatCompletionOptions = new ChatCompletionOptions(); - var chatCompletionOptionsJsonModel = chatCompletionOptions as IJsonModel; - Debug.Assert(chatCompletionOptionsJsonModel is not null); - - chatCompletionOptions = chatCompletionOptionsJsonModel.Create(requestBinary, ModelReaderWriterOptions.Json); - if (chatCompletionOptions is null) - { - return Results.BadRequest("Invalid request payload."); - } - - return await chatCompletionsProcessor.CreateChatCompletionAsync(chatCompletionOptions, cancellationToken).ConfigureAwait(false); - }).WithName(endpointAgentName + "/CreateChatCompletion"); + return group; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/HostApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/HostApplicationBuilderExtensions.cs index f4bfeb2578..dc6a9c5ed0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/HostApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/HostApplicationBuilderExtensions.cs @@ -8,10 +8,24 @@ using Microsoft.Extensions.Hosting; namespace Microsoft.Extensions.Hosting; /// -/// Extension methods for to configure OpenAI Responses support. +/// Extension methods for to configure OpenAI support. /// public static class MicrosoftAgentAIHostingOpenAIHostApplicationBuilderExtensions { + /// + /// Adds support for exposing instances via OpenAI ChatCompletions. + /// + /// The to configure. + /// The for method chaining. + public static IHostApplicationBuilder AddOpenAIChatCompletions(this IHostApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddOpenAIChatCompletions(); + + return builder; + } + /// /// Adds support for exposing instances via OpenAI Responses. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGeneratorHelpers.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGeneratorHelpers.cs new file mode 100644 index 0000000000..6f2e1017d6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/IdGeneratorHelpers.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Security.Cryptography; +using System.Text.RegularExpressions; + +namespace Microsoft.Agents.AI.Hosting.OpenAI; + +/// +/// Shared helpers to generate IDs. +/// +internal static partial class IdGeneratorHelpers +{ +#if NET9_0_OR_GREATER + [GeneratedRegex("^[A-Za-z0-9]+$")] + private static partial Regex WatermarkRegex(); +#else + private static readonly Regex s_watermarkRegex = new("^[A-Za-z0-9]+$", RegexOptions.Compiled); + private static Regex WatermarkRegex() => s_watermarkRegex; +#endif + + /// + /// Generates a new ID with a structured format that includes a partition key. + /// + /// The prefix to add to the ID, typically indicating the resource type. + /// The length of the random entropy string in the ID. + /// The length of the partition key if generating a new one. + /// Optional additional text to insert between the prefix and the entropy. + /// Optional text to insert in the middle of the entropy string for traceability. + /// The delimiter character used to separate parts of the ID. + /// An explicit partition key to use. When provided, this value will be used instead of generating a new one. + /// An existing ID to extract the partition key from. When provided, the same partition key will be used instead of generating a new one. + /// A new ID with format "{prefix}{delimiter}{infix}{entropy}{delimiter}{partitionKey}". + /// Thrown when the watermark contains non-alphanumeric characters. + public static string NewId(string prefix, int stringLength = 32, int partitionKeyLength = 16, string infix = "", + string watermark = "", string delimiter = "_", string? partitionKey = null, string partitionKeyHint = "") + { + ArgumentOutOfRangeException.ThrowIfLessThan(stringLength, 1); + var entropy = GetRandomString(stringLength); + + string pKey = partitionKey ?? GetPartitionIdOrDefault(partitionKeyHint) ?? GetRandomString(partitionKeyLength); + + if (!string.IsNullOrEmpty(watermark)) + { + if (!WatermarkRegex().IsMatch(watermark)) + { + throw new ArgumentException($"Only alphanumeric characters may be in watermark: {watermark}", + nameof(watermark)); + } + + entropy = $"{entropy[..(stringLength / 2)]}{watermark}{entropy[(stringLength / 2)..]}"; + } + + infix ??= ""; + prefix = !string.IsNullOrEmpty(prefix) ? $"{prefix}{delimiter}" : ""; + return $"{prefix}{infix}{entropy}{pKey}"; + } + + /// + /// Generates a secure random alphanumeric string of the specified length. + /// + /// The desired length of the random string. + /// A random alphanumeric string. + /// Thrown when stringLength is less than 1. + public static string GetRandomString(int stringLength) => + RandomNumberGenerator.GetString("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", stringLength); + + /// + /// Extracts the partition key from an existing ID, or returns null if extraction fails. + /// + /// The ID to extract the partition key from. + /// The length of the random entropy string in the ID. + /// The length of the partition key if generating a new one. + /// The delimiter character used in the ID. + /// The partition key if successfully extracted; otherwise, null. + public static string? GetPartitionIdOrDefault(string? id, int stringLength = 32, int partitionKeyLength = 16, + string delimiter = "_") + { + if (string.IsNullOrEmpty(id)) + { + return null; + } + + var parts = id.Split([delimiter], StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + { + return null; + } + + if (parts[1].Length < stringLength + partitionKeyLength) + { + return null; + } + + // get last partitionKeyLength characters from the last part as the partition key + return parts[1][^partitionKeyLength..]; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IdGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IdGenerator.cs index 63ec1a85bd..c532390371 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IdGenerator.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/IdGenerator.cs @@ -1,8 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; -using System.Security.Cryptography; -using System.Text.RegularExpressions; using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses; @@ -14,14 +11,6 @@ internal sealed partial class IdGenerator { private readonly string _partitionId; -#if NET9_0_OR_GREATER - [GeneratedRegex("^[A-Za-z0-9]+$")] - private static partial Regex WatermarkRegex(); -#else - private static readonly Regex s_watermarkRegex = new("^[A-Za-z0-9]+$", RegexOptions.Compiled); - private static Regex WatermarkRegex() => s_watermarkRegex; -#endif - /// /// Initializes a new instance of the class. /// @@ -29,9 +18,9 @@ internal sealed partial class IdGenerator /// The conversation ID. public IdGenerator(string? responseId, string? conversationId) { - this.ResponseId = responseId ?? NewId("resp"); - this.ConversationId = conversationId ?? NewId("conv"); - this._partitionId = GetPartitionIdOrDefault(this.ConversationId) ?? string.Empty; + this.ResponseId = responseId ?? IdGeneratorHelpers.NewId("resp"); + this.ConversationId = conversationId ?? IdGeneratorHelpers.NewId("conv"); + this._partitionId = IdGeneratorHelpers.GetPartitionIdOrDefault(this.ConversationId) ?? string.Empty; } /// @@ -64,7 +53,7 @@ internal sealed partial class IdGenerator public string Generate(string? category = null) { var prefix = string.IsNullOrEmpty(category) ? "id" : category; - return NewId(prefix, partitionKey: this._partitionId); + return IdGeneratorHelpers.NewId(prefix, partitionKey: this._partitionId); } /// @@ -90,81 +79,4 @@ internal sealed partial class IdGenerator /// /// A reasoning ID. public string GenerateReasoningId() => this.Generate("rs"); - - /// - /// Generates a new ID with a structured format that includes a partition key. - /// - /// The prefix to add to the ID, typically indicating the resource type. - /// The length of the random entropy string in the ID. - /// The length of the partition key if generating a new one. - /// Optional additional text to insert between the prefix and the entropy. - /// Optional text to insert in the middle of the entropy string for traceability. - /// The delimiter character used to separate parts of the ID. - /// An explicit partition key to use. When provided, this value will be used instead of generating a new one. - /// An existing ID to extract the partition key from. When provided, the same partition key will be used instead of generating a new one. - /// A new ID with format "{prefix}{delimiter}{infix}{entropy}{delimiter}{partitionKey}". - /// Thrown when the watermark contains non-alphanumeric characters. - private static string NewId(string prefix, int stringLength = 32, int partitionKeyLength = 16, string infix = "", - string watermark = "", string delimiter = "_", string? partitionKey = null, string partitionKeyHint = "") - { - ArgumentOutOfRangeException.ThrowIfLessThan(stringLength, 1); - var entropy = GetRandomString(stringLength); - - string pKey = partitionKey ?? GetPartitionIdOrDefault(partitionKeyHint) ?? GetRandomString(partitionKeyLength); - - if (!string.IsNullOrEmpty(watermark)) - { - if (!WatermarkRegex().IsMatch(watermark)) - { - throw new ArgumentException($"Only alphanumeric characters may be in watermark: {watermark}", - nameof(watermark)); - } - - entropy = $"{entropy[..(stringLength / 2)]}{watermark}{entropy[(stringLength / 2)..]}"; - } - - infix ??= ""; - prefix = !string.IsNullOrEmpty(prefix) ? $"{prefix}{delimiter}" : ""; - return $"{prefix}{infix}{entropy}{pKey}"; - } - - /// - /// Generates a secure random alphanumeric string of the specified length. - /// - /// The desired length of the random string. - /// A random alphanumeric string. - /// Thrown when stringLength is less than 1. - private static string GetRandomString(int stringLength) => - RandomNumberGenerator.GetString("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", stringLength); - - /// - /// Extracts the partition key from an existing ID, or returns null if extraction fails. - /// - /// The ID to extract the partition key from. - /// The length of the random entropy string in the ID. - /// The length of the partition key if generating a new one. - /// The delimiter character used in the ID. - /// The partition key if successfully extracted; otherwise, null. - private static string? GetPartitionIdOrDefault(string? id, int stringLength = 32, int partitionKeyLength = 16, - string delimiter = "_") - { - if (string.IsNullOrEmpty(id)) - { - return null; - } - - var parts = id.Split([delimiter], StringSplitOptions.RemoveEmptyEntries); - if (parts.Length < 2) - { - return null; - } - - if (parts[1].Length < stringLength + partitionKeyLength) - { - return null; - } - - // get last partitionKeyLength characters from the last part as the partition key - return parts[1][^partitionKeyLength..]; - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs index 7fab6586a9..d4ea17e912 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ServiceCollectionExtensions.cs @@ -2,16 +2,31 @@ using System; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; using Microsoft.Agents.AI.Hosting.OpenAI.Responses; using Microsoft.AspNetCore.Http.Json; namespace Microsoft.Extensions.DependencyInjection; /// -/// Extension methods for to configure OpenAI Responses support. +/// Extension methods for to configure OpenAI support. /// public static class MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions { + /// + /// Adds support for exposing instances via OpenAI ChatCompletions. + /// + /// The to configure. + /// The for method chaining. + public static IServiceCollection AddOpenAIChatCompletions(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.Configure(options => options.SerializerOptions.TypeInfoResolverChain.Add(ChatCompletionsJsonSerializerOptions.Default.TypeInfoResolver!)); + + return services; + } + /// /// Adds support for exposing instances via OpenAI Responses. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTestBase.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTestBase.cs index eb6de34fff..18e67267d6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTestBase.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTestBase.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net.Http; using System.Text; using System.Text.Json; @@ -22,16 +23,19 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Tests; /// public abstract class ConformanceTestBase : IAsyncDisposable { - protected const string TracesBasePath = "ConformanceTraces/Responses"; + protected const string TracesBasePath = "ConformanceTraces"; + protected const string ResponsesTracesDirectory = "Responses"; + protected const string ChatCompletionsTracesDirectory = "ChatCompletions"; + private WebApplication? _app; private HttpClient? _httpClient; /// /// Loads a JSON file from the conformance traces directory. /// - protected static string LoadTraceFile(string relativePath) + protected static string LoadTraceFile(string directory, string relativePath) { - var fullPath = Path.Combine(TracesBasePath, relativePath); + var fullPath = Path.Combine(TracesBasePath, directory, relativePath); if (!File.Exists(fullPath)) { @@ -41,12 +45,33 @@ public abstract class ConformanceTestBase : IAsyncDisposable return File.ReadAllText(fullPath); } + /// + /// Loads a JSON file from the conformance traces directory. + /// + protected static string LoadResponsesTraceFile(string relativePath) + => LoadTraceFile(ResponsesTracesDirectory, relativePath); + /// /// Loads a JSON document from the conformance traces directory. /// - protected static JsonDocument LoadTraceDocument(string relativePath) + protected static JsonDocument LoadResponsesTraceDocument(string relativePath) { - var json = LoadTraceFile(relativePath); + var json = LoadResponsesTraceFile(relativePath); + return JsonDocument.Parse(json); + } + + /// + /// Loads a JSON file from the conformance traces directory. + /// + protected static string LoadChatCompletionsTraceFile(string relativePath) + => LoadTraceFile(ChatCompletionsTracesDirectory, relativePath); + + /// + /// Loads a JSON document from the conformance traces directory. + /// + protected static JsonDocument LoadChatCompletionsTraceDocument(string relativePath) + { + var json = LoadChatCompletionsTraceFile(relativePath); return JsonDocument.Parse(json); } @@ -61,6 +86,20 @@ public abstract class ConformanceTestBase : IAsyncDisposable } } + /// + /// Asserts that a JSON element has any of the passed string values. + /// + protected static void AssertJsonPropertyEquals(JsonElement element, string propertyName, params string[] anyOfValues) + { + AssertJsonPropertyExists(element, propertyName); + var actualValue = element.GetProperty(propertyName).GetString(); + + if (!anyOfValues.Contains(actualValue)) + { + throw new Xunit.Sdk.XunitException($"Property '{propertyName}': expected any of '{string.Join("; ", anyOfValues)}', got '{actualValue}'"); + } + } + /// /// Asserts that a JSON element has a specific string value. /// @@ -75,6 +114,20 @@ public abstract class ConformanceTestBase : IAsyncDisposable } } + /// + /// Asserts that a JSON element has a specific string value. + /// + protected static void AssertJsonPropertyEquals(JsonElement element, string propertyName, float expectedValue) + { + AssertJsonPropertyExists(element, propertyName); + var actualValue = element.GetProperty(propertyName).GetDouble(); + + if (actualValue != expectedValue) + { + throw new Xunit.Sdk.XunitException($"Property '{propertyName}': expected '{expectedValue}', got '{actualValue}'"); + } + } + /// /// Asserts that a JSON element has a specific integer value. /// @@ -141,10 +194,12 @@ public abstract class ConformanceTestBase : IAsyncDisposable builder.Services.AddKeyedSingleton("chat-client", mockChatClient); builder.AddAIAgent(agentName, instructions, chatClientServiceKey: "chat-client"); builder.AddOpenAIResponses(); + builder.AddOpenAIChatCompletions(); this._app = builder.Build(); AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); this._app.MapOpenAIResponses(agent); + this._app.MapOpenAIChatCompletions(agent); await this._app.StartAsync(); @@ -171,10 +226,12 @@ public abstract class ConformanceTestBase : IAsyncDisposable builder.Services.AddKeyedSingleton("chat-client", mockChatClient); builder.AddAIAgent(agentName, instructions, chatClientServiceKey: "chat-client"); builder.AddOpenAIResponses(); + builder.AddOpenAIChatCompletions(); this._app = builder.Build(); AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); this._app.MapOpenAIResponses(agent); + this._app.MapOpenAIChatCompletions(agent); await this._app.StartAsync(); @@ -188,12 +245,21 @@ public abstract class ConformanceTestBase : IAsyncDisposable /// /// Sends a POST request with JSON content to the test server. /// - protected async Task SendRequestAsync(HttpClient client, string agentName, string requestJson) + protected async Task SendResponsesRequestAsync(HttpClient client, string agentName, string requestJson) { StringContent content = new(requestJson, Encoding.UTF8, "application/json"); return await client.PostAsync(new Uri($"/{agentName}/v1/responses", UriKind.Relative), content); } + /// + /// Sends a POST request with JSON content to the test server. + /// + protected async Task SendChatCompletionRequestAsync(HttpClient client, string agentName, string requestJson) + { + StringContent content = new(requestJson, Encoding.UTF8, "application/json"); + return await client.PostAsync(new Uri($"/{agentName}/v1/chat/completions", UriKind.Relative), content); + } + /// /// Parses the response JSON and returns a JsonDocument. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/request.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/request.json new file mode 100644 index 0000000000..0dc658fcd2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/request.json @@ -0,0 +1,12 @@ +{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Hello, how are you?" + } + ], + "max_completion_tokens": 100, + "temperature": 1.0, + "top_p": 1.0 +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/response.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/response.json new file mode 100644 index 0000000000..e344e37511 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/basic/response.json @@ -0,0 +1,33 @@ +{ + "id": "chatcmpl-AaBbCcDdEeFfGg", + "object": "chat.completion", + "created": 1730371200, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! I'm doing well, thank you. How about you?" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 13, + "completion_tokens": 14, + "total_tokens": 27, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_1234567890" +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/request.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/request.json new file mode 100644 index 0000000000..9d3defd0e7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/request.json @@ -0,0 +1,34 @@ +{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "What's the weather in San Francisco?" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather in a given location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA" + }, + "unit": { + "type": "string", + "enum": [ "celsius", "fahrenheit" ], + "description": "The unit of temperature" + } + }, + "required": [ "location" ] + } + } + } + ], + "tool_choice": "auto" +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/response.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/response.json new file mode 100644 index 0000000000..bfe11a2e07 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/function_calling/response.json @@ -0,0 +1,43 @@ +{ + "id": "chatcmpl-DEF456", + "object": "chat.completion", + "created": 1730371250, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_abc123xyz", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\":\"San Francisco, CA\",\"unit\":\"fahrenheit\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 85, + "completion_tokens": 18, + "total_tokens": 103, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_1234567890" +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/request.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/request.json new file mode 100644 index 0000000000..6e30064210 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/request.json @@ -0,0 +1,36 @@ +{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant that outputs JSON." + }, + { + "role": "user", + "content": "Provide information about a person named John Doe, age 30, who is a software engineer." + } + ], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "person_info", + "strict": true, + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "age": { + "type": "number" + }, + "occupation": { + "type": "string" + } + }, + "required": [ "name", "age", "occupation" ], + "additionalProperties": false + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/response.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/response.json new file mode 100644 index 0000000000..72b06686d5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/json_mode/response.json @@ -0,0 +1,33 @@ +{ + "id": "chatcmpl-MNO345", + "object": "chat.completion", + "created": 1730371400, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{\"name\":\"John Doe\",\"age\":30,\"occupation\":\"software engineer\"}" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 45, + "completion_tokens": 18, + "total_tokens": 63, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_5544332211" +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/request.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/request.json new file mode 100644 index 0000000000..c749590b15 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/request.json @@ -0,0 +1,18 @@ +{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "What is 2+2?" + }, + { + "role": "assistant", + "content": "2+2 equals 4." + }, + { + "role": "user", + "content": "What about 3+3?" + } + ], + "max_completion_tokens": 50 +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/response.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/response.json new file mode 100644 index 0000000000..b695db84ce --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/multi_turn/response.json @@ -0,0 +1,33 @@ +{ + "id": "chatcmpl-JKL012", + "object": "chat.completion", + "created": 1730371350, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "3+3 equals 6." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 35, + "completion_tokens": 8, + "total_tokens": 43, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_1122334455" +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/request.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/request.json new file mode 100644 index 0000000000..f224d8d953 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/request.json @@ -0,0 +1,12 @@ +{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Write a short poem about AI." + } + ], + "max_completion_tokens": 150, + "temperature": 1.0, + "stream": true +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/response.txt b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/response.txt new file mode 100644 index 0000000000..aa0261cb80 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/streaming/response.txt @@ -0,0 +1,21 @@ +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":"In"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":" circuits"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":" bright"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":","},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":" minds"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":" take"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":" flight"},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{"content":"."},"finish_reason":null}]} + +data: {"id":"chatcmpl-ABC123","object":"chat.completion.chunk","created":1730371200,"model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":12,"total_tokens":24,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}}} + +data: [DONE] \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/request.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/request.json new file mode 100644 index 0000000000..416939d213 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/request.json @@ -0,0 +1,14 @@ +{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant that speaks like a pirate." + }, + { + "role": "user", + "content": "Tell me about the ocean." + } + ], + "max_completion_tokens": 100 +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/response.json b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/response.json new file mode 100644 index 0000000000..ddda144675 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ConformanceTraces/ChatCompletions/system_message/response.json @@ -0,0 +1,33 @@ +{ + "id": "chatcmpl-GHI789", + "object": "chat.completion", + "created": 1730371300, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Ahoy, matey! The ocean be a vast, mysterious realm full of treasures and creatures!" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 28, + "completion_tokens": 20, + "total_tokens": 48, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_9876543210" +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ContentTypeEventGeneratorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ContentTypeEventGeneratorTests.cs index 80b2f6781d..5a8f4ea442 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ContentTypeEventGeneratorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/ContentTypeEventGeneratorTests.cs @@ -36,7 +36,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase ]); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -76,7 +76,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase ]); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -111,7 +111,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase ]); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -144,7 +144,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateErrorContentAgentAsync(AgentName, ErrorMessage); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -179,7 +179,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateErrorContentAgentAsync(AgentName, ErrorMessage); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -213,7 +213,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateErrorContentAgentAsync(AgentName, "Error message"); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -240,7 +240,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateImageContentAgentAsync(AgentName, ImageUrl, isDataUri: false); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -264,7 +264,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateImageContentAgentAsync(AgentName, DataUri, isDataUri: true); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -289,7 +289,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateImageContentWithDetailAgentAsync(AgentName, ImageUrl, Detail); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -313,7 +313,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateImageContentAgentAsync(AgentName, "https://example.com/test.png", isDataUri: false); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -339,7 +339,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateAudioContentAgentAsync(AgentName, AudioDataUri, "audio/mpeg"); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -364,7 +364,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateAudioContentAgentAsync(AgentName, AudioDataUri, "audio/wav"); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -390,7 +390,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateAudioContentAgentAsync(AgentName, AudioDataUri, mediaType); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -415,7 +415,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateHostedFileContentAgentAsync(AgentName, FileId); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -438,7 +438,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateHostedFileContentAgentAsync(AgentName, "file-xyz789"); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -465,7 +465,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateFileContentAgentAsync(AgentName, FileDataUri, Filename); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -490,7 +490,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateFileContentAgentAsync(AgentName, FileDataUri, null); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -516,7 +516,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateMixedContentAgentAsync(AgentName); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -535,7 +535,7 @@ public sealed class ContentTypeEventGeneratorTests : ConformanceTestBase HttpClient client = await this.CreateErrorAndTextContentAgentAsync(AgentName); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, AgentName, StreamingRequestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, AgentName, StreamingRequestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj index ff200a7296..5d081b452e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj @@ -1,4 +1,4 @@ - + $(ProjectsCoreTargetFrameworks) @@ -27,4 +27,16 @@ + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs new file mode 100644 index 0000000000..b777db0ce5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsConformanceTests.cs @@ -0,0 +1,495 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.OpenAI.Tests; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Conformance tests for OpenAI Chat Completions API implementation behavior. +/// Tests use real API traces to ensure our implementation produces responses +/// that match OpenAI's wire format when processing actual requests through the server. +/// +public sealed class OpenAIChatCompletionsConformanceTests : ConformanceTestBase +{ + [Fact] + public async Task BasicRequestResponseAsync() + { + // Arrange + string requestJson = LoadChatCompletionsTraceFile("basic/request.json"); + using var expectedResponseDoc = LoadChatCompletionsTraceDocument("basic/response.json"); + var expectedResponse = expectedResponseDoc.RootElement; + + // Get the expected response text from the trace to use as mock response + string expectedText = expectedResponse.GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content").GetString()!; + + HttpClient client = await this.CreateTestServerAsync("basic-agent", "You are a helpful assistant.", expectedText); + + // Act + HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "basic-agent", requestJson); + using var responseDoc = await ParseResponseAsync(httpResponse); + var response = responseDoc.RootElement; + + // Parse the request to verify it was sent correctly + using var requestDoc = JsonDocument.Parse(requestJson); + var request = requestDoc.RootElement; + + // Assert - Verify request was properly formatted (structure check) + AssertJsonPropertyEquals(request, "model", "gpt-4o-mini"); + AssertJsonPropertyExists(request, "messages"); + AssertJsonPropertyEquals(request, "max_completion_tokens", 100); + AssertJsonPropertyEquals(request, "temperature", 1.0f); + AssertJsonPropertyEquals(request, "top_p", 1.0f); + + var messages = request.GetProperty("messages"); + Assert.Equal(JsonValueKind.Array, messages.ValueKind); + Assert.True(messages.GetArrayLength() > 0, "Messages array should not be empty"); + + var firstMessage = messages[0]; + AssertJsonPropertyEquals(firstMessage, "role", "user"); + AssertJsonPropertyEquals(firstMessage, "content", "Hello, how are you?"); + + // Assert - Response metadata (IDs and timestamps are dynamic, just verify structure) + AssertJsonPropertyExists(response, "id"); + AssertJsonPropertyEquals(response, "object", "chat.completion"); + AssertJsonPropertyExists(response, "created"); + AssertJsonPropertyExists(response, "model"); + + var id = response.GetProperty("id").GetString(); + Assert.NotNull(id); + Assert.StartsWith("chatcmpl-", id); + + var createdAt = response.GetProperty("created").GetInt64(); + Assert.True(createdAt > 0, "created should be a positive unix timestamp"); + + var model = response.GetProperty("model").GetString(); + Assert.NotNull(model); + Assert.StartsWith("gpt-4o-mini", model); + + // Assert - Choices array structure + AssertJsonPropertyExists(response, "choices"); + var choices = response.GetProperty("choices"); + Assert.Equal(JsonValueKind.Array, choices.ValueKind); + Assert.True(choices.GetArrayLength() > 0, "Choices array should not be empty"); + + // Assert - Choice structure + var firstChoice = choices[0]; + AssertJsonPropertyExists(firstChoice, "index"); + AssertJsonPropertyEquals(firstChoice, "index", 0); + AssertJsonPropertyExists(firstChoice, "message"); + AssertJsonPropertyExists(firstChoice, "finish_reason"); + + var finishReason = firstChoice.GetProperty("finish_reason").GetString(); + Assert.NotNull(finishReason); + Assert.Contains(finishReason, collection: ["stop", "length", "content_filter", "tool_calls"]); + + // Assert - Message structure + var message = firstChoice.GetProperty("message"); + AssertJsonPropertyExists(message, "role"); + AssertJsonPropertyEquals(message, "role", "assistant"); + AssertJsonPropertyExists(message, "content"); + + var content = message.GetProperty("content").GetString(); + Assert.NotNull(content); + Assert.Equal(expectedText, content); // Verify actual content matches expected + + // Assert - Usage statistics + AssertJsonPropertyExists(response, "usage"); + var usage = response.GetProperty("usage"); + AssertJsonPropertyExists(usage, "prompt_tokens"); + AssertJsonPropertyExists(usage, "completion_tokens"); + AssertJsonPropertyExists(usage, "total_tokens"); + + var promptTokens = usage.GetProperty("prompt_tokens").GetInt32(); + var completionTokens = usage.GetProperty("completion_tokens").GetInt32(); + var totalTokens = usage.GetProperty("total_tokens").GetInt32(); + + Assert.True(promptTokens > 0, "prompt_tokens should be positive"); + Assert.True(completionTokens > 0, "completion_tokens should be positive"); + Assert.Equal(promptTokens + completionTokens, totalTokens); + + // Assert - Usage details + AssertJsonPropertyExists(usage, "prompt_tokens_details"); + var promptDetails = usage.GetProperty("prompt_tokens_details"); + AssertJsonPropertyExists(promptDetails, "cached_tokens"); + AssertJsonPropertyExists(promptDetails, "audio_tokens"); + Assert.True(promptDetails.GetProperty("cached_tokens").GetInt32() >= 0); + Assert.True(promptDetails.GetProperty("audio_tokens").GetInt32() >= 0); + + AssertJsonPropertyExists(usage, "completion_tokens_details"); + var completionDetails = usage.GetProperty("completion_tokens_details"); + AssertJsonPropertyExists(completionDetails, "reasoning_tokens"); + AssertJsonPropertyExists(completionDetails, "audio_tokens"); + AssertJsonPropertyExists(completionDetails, "accepted_prediction_tokens"); + AssertJsonPropertyExists(completionDetails, "rejected_prediction_tokens"); + Assert.True(completionDetails.GetProperty("reasoning_tokens").GetInt32() >= 0); + Assert.True(completionDetails.GetProperty("audio_tokens").GetInt32() >= 0); + Assert.True(completionDetails.GetProperty("accepted_prediction_tokens").GetInt32() >= 0); + Assert.True(completionDetails.GetProperty("rejected_prediction_tokens").GetInt32() >= 0); + + // Assert - Optional fields + AssertJsonPropertyExists(response, "service_tier"); + var serviceTier = response.GetProperty("service_tier").GetString(); + Assert.NotNull(serviceTier); + Assert.True(serviceTier == "default" || serviceTier == "auto", $"service_tier should be 'default' or 'auto', got '{serviceTier}'"); + } + + [Fact] + public async Task StreamingRequestResponseAsync() + { + // Arrange + string requestJson = LoadChatCompletionsTraceFile("streaming/request.json"); + string expectedResponseSse = LoadChatCompletionsTraceFile("streaming/response.txt"); + + // Extract expected text from SSE chunks + var expectedChunks = ParseChatCompletionChunksFromSse(expectedResponseSse); + string expectedText = string.Concat(expectedChunks + .Where(c => c.GetProperty("choices")[0].GetProperty("delta").TryGetProperty("content", out var content)) + .Select(c => c.GetProperty("choices")[0].GetProperty("delta").GetProperty("content").GetString())); + + HttpClient client = await this.CreateTestServerAsync("streaming-agent", "You are a helpful assistant.", expectedText); + + // Act + HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "streaming-agent", requestJson); + + // Assert - Response should be SSE format + Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType); + + string responseSse = await httpResponse.Content.ReadAsStringAsync(); + var chunks = ParseChatCompletionChunksFromSse(responseSse); + + // Parse the request + using var requestDoc = JsonDocument.Parse(requestJson); + var request = requestDoc.RootElement; + + // Assert - Request has stream flag + AssertJsonPropertyEquals(request, "stream", true); + + // Assert - Response has valid chunks + Assert.NotEmpty(chunks); + + // Assert - All chunks have same ID + string? firstId = null; + foreach (var chunk in chunks) + { + AssertJsonPropertyExists(chunk, "id"); + AssertJsonPropertyEquals(chunk, "object", "chat.completion.chunk"); + AssertJsonPropertyExists(chunk, "created"); + AssertJsonPropertyExists(chunk, "model"); + AssertJsonPropertyExists(chunk, "choices"); + + string chunkId = chunk.GetProperty("id").GetString()!; + Assert.StartsWith("chatcmpl-", chunkId); + + firstId ??= chunkId; + Assert.Equal(firstId, chunkId); + } + + // Assert - First chunk has role + var firstChunk = chunks[0]; + var firstChoice = firstChunk.GetProperty("choices")[0]; + AssertJsonPropertyExists(firstChoice, "delta"); + var firstDelta = firstChoice.GetProperty("delta"); + if (firstDelta.TryGetProperty("role", out var role)) + { + Assert.Equal("assistant", role.GetString()); + } + + // Assert - Content chunks have delta content + var contentChunks = chunks.Where(c => + c.GetProperty("choices")[0].GetProperty("delta").TryGetProperty("content", out _)).ToList(); + Assert.NotEmpty(contentChunks); + + // Assert - Last chunk has finish_reason + var lastChunk = chunks[^1]; + var lastChoice = lastChunk.GetProperty("choices")[0]; + if (lastChoice.TryGetProperty("finish_reason", out var finishReason) && finishReason.ValueKind != JsonValueKind.Null) + { + string reason = finishReason.GetString()!; + Assert.Contains(reason, collection: ["stop", "length", "tool_calls", "content_filter"]); + } + + // Assert - Last chunk may have usage + if (lastChunk.TryGetProperty("usage", out var usage)) + { + AssertJsonPropertyExists(usage, "prompt_tokens"); + AssertJsonPropertyExists(usage, "completion_tokens"); + AssertJsonPropertyExists(usage, "total_tokens"); + } + + // Assert - Accumulated content matches expected + string accumulatedText = string.Concat(contentChunks + .Select(c => c.GetProperty("choices")[0].GetProperty("delta").GetProperty("content").GetString())); + Assert.NotEmpty(accumulatedText); + } + + [Fact] + public async Task FunctionCallingRequestResponseAsync() + { + // Arrange + string requestJson = LoadChatCompletionsTraceFile("function_calling/request.json"); + using var expectedResponseDoc = LoadChatCompletionsTraceDocument("function_calling/response.json"); + var expectedResponse = expectedResponseDoc.RootElement; + + // Get expected function call details + const string FunctionName = "get_weather"; + + HttpClient client = await this.CreateTestServerAsync("function-agent", "You are a helpful assistant.", FunctionName, + (msg) => [new FunctionCallContent("call_abc123xyz", "get_weather", new Dictionary() { + { "location", "San Francisco, CA" }, + { "unit", "fahrenheit" } + })] + ); + + // Act + HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "function-agent", requestJson); + using var responseDoc = await ParseResponseAsync(httpResponse); + var response = responseDoc.RootElement; + + // Parse the request + using var requestDoc = JsonDocument.Parse(requestJson); + var request = requestDoc.RootElement; + + // Assert - Request has tools array + AssertJsonPropertyExists(request, "tools"); + var tools = request.GetProperty("tools"); + Assert.Equal(JsonValueKind.Array, tools.ValueKind); + Assert.True(tools.GetArrayLength() > 0); + + // Assert - Tool structure + var tool = tools[0]; + AssertJsonPropertyEquals(tool, "type", "function"); + AssertJsonPropertyExists(tool, "function"); + var function = tool.GetProperty("function"); + AssertJsonPropertyEquals(function, "name", "get_weather"); + AssertJsonPropertyExists(function, "description"); + AssertJsonPropertyExists(function, "parameters"); + + // Assert - Parameters have JSON Schema + var parameters = function.GetProperty("parameters"); + AssertJsonPropertyEquals(parameters, "type", "object"); + AssertJsonPropertyExists(parameters, "properties"); + AssertJsonPropertyExists(parameters, "required"); + + // Assert - Response has tool_calls. Not always will return that, so can default to "stop" + var choices = response.GetProperty("choices"); + var choice = choices[0]; + var message = choice.GetProperty("message"); + AssertJsonPropertyEquals(choice, "finish_reason", ["tool_calls", "stop"]); + AssertJsonPropertyExists(message, "tool_calls"); + + // Assert - Tool call structure + var toolCalls = message.GetProperty("tool_calls"); + Assert.Equal(JsonValueKind.Array, toolCalls.ValueKind); + Assert.True(toolCalls.GetArrayLength() > 0); + + var toolCall = toolCalls[0]; + AssertJsonPropertyExists(toolCall, "id"); + AssertJsonPropertyEquals(toolCall, "type", "function"); + AssertJsonPropertyExists(toolCall, "function"); + + var callFunction = toolCall.GetProperty("function"); + AssertJsonPropertyEquals(callFunction, "name", "get_weather"); + AssertJsonPropertyExists(callFunction, "arguments"); + + // Assert - Arguments are valid JSON + string arguments = callFunction.GetProperty("arguments").GetString()!; + using var argsDoc = JsonDocument.Parse(arguments); + var argsRoot = argsDoc.RootElement; + AssertJsonPropertyExists(argsRoot, "location"); + + // Assert - Message content is null when tool_calls present. Can be absent or null. + if (message.TryGetProperty("content", out var contentProp)) + { + Assert.Equal(JsonValueKind.Null, contentProp.ValueKind); + } + } + + [Fact] + public async Task SystemMessageRequestResponseAsync() + { + // Arrange + string requestJson = LoadChatCompletionsTraceFile("system_message/request.json"); + using var expectedResponseDoc = LoadChatCompletionsTraceDocument("system_message/response.json"); + var expectedResponse = expectedResponseDoc.RootElement; + + string expectedText = expectedResponse.GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content").GetString()!; + + HttpClient client = await this.CreateTestServerAsync("system-agent", "You are a helpful assistant that speaks like a pirate.", expectedText); + + // Act + HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "system-agent", requestJson); + using var responseDoc = await ParseResponseAsync(httpResponse); + var response = responseDoc.RootElement; + + // Parse the request + using var requestDoc = JsonDocument.Parse(requestJson); + var request = requestDoc.RootElement; + + // Assert - Request has messages with system role + var messages = request.GetProperty("messages"); + Assert.True(messages.GetArrayLength() >= 2); + + var systemMessage = messages[0]; + AssertJsonPropertyEquals(systemMessage, "role", "system"); + AssertJsonPropertyExists(systemMessage, "content"); + string systemContent = systemMessage.GetProperty("content").GetString()!; + Assert.Contains("pirate", systemContent, System.StringComparison.OrdinalIgnoreCase); + + var userMessage = messages[1]; + AssertJsonPropertyEquals(userMessage, "role", "user"); + + // Assert - Response reflects system message influence + var responseMessage = response.GetProperty("choices")[0].GetProperty("message"); + string content = responseMessage.GetProperty("content").GetString()!; + Assert.NotNull(content); + Assert.Equal(expectedText, content); + } + + [Fact] + public async Task MultiTurnConversationRequestResponseAsync() + { + // Arrange + string requestJson = LoadChatCompletionsTraceFile("multi_turn/request.json"); + using var expectedResponseDoc = LoadChatCompletionsTraceDocument("multi_turn/response.json"); + var expectedResponse = expectedResponseDoc.RootElement; + + string expectedText = expectedResponse.GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content").GetString()!; + + HttpClient client = await this.CreateTestServerAsync("multi-turn-agent", "You are a helpful assistant.", expectedText); + + // Act + HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "multi-turn-agent", requestJson); + using var responseDoc = await ParseResponseAsync(httpResponse); + var response = responseDoc.RootElement; + + // Parse the request + using var requestDoc = JsonDocument.Parse(requestJson); + var request = requestDoc.RootElement; + + // Assert - Request has conversation history + var messages = request.GetProperty("messages"); + Assert.True(messages.GetArrayLength() >= 3, "Should have at least 3 messages for multi-turn"); + + // Assert - Message sequence alternates between user and assistant + AssertJsonPropertyEquals(messages[0], "role", "user"); + AssertJsonPropertyEquals(messages[1], "role", "assistant"); + AssertJsonPropertyEquals(messages[2], "role", "user"); + + // Assert - Response continues conversation + var responseMessage = response.GetProperty("choices")[0].GetProperty("message"); + AssertJsonPropertyEquals(responseMessage, "role", "assistant"); + string content = responseMessage.GetProperty("content").GetString()!; + Assert.NotNull(content); + Assert.Equal(expectedText, content); + + // Assert - Usage tokens account for conversation history + var usage = response.GetProperty("usage"); + int promptTokens = usage.GetProperty("prompt_tokens").GetInt32(); + Assert.True(promptTokens > 20, "Prompt tokens should account for conversation history"); + } + + [Fact] + public async Task JsonModeRequestResponseAsync() + { + // Arrange + string requestJson = LoadChatCompletionsTraceFile("json_mode/request.json"); + using var expectedResponseDoc = LoadChatCompletionsTraceDocument("json_mode/response.json"); + var expectedResponse = expectedResponseDoc.RootElement; + + string expectedText = expectedResponse.GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content").GetString()!; + + HttpClient client = await this.CreateTestServerAsync("json-agent", "You are a helpful assistant that outputs JSON.", expectedText); + + // Act + HttpResponseMessage httpResponse = await this.SendChatCompletionRequestAsync(client, "json-agent", requestJson); + using var responseDoc = await ParseResponseAsync(httpResponse); + var response = responseDoc.RootElement; + + // Parse the request + using var requestDoc = JsonDocument.Parse(requestJson); + var request = requestDoc.RootElement; + + // Assert - Request has response_format with json_schema + AssertJsonPropertyExists(request, "response_format"); + var responseFormat = request.GetProperty("response_format"); + AssertJsonPropertyEquals(responseFormat, "type", "json_schema"); + AssertJsonPropertyExists(responseFormat, "json_schema"); + + var jsonSchema = responseFormat.GetProperty("json_schema"); + AssertJsonPropertyEquals(jsonSchema, "name", "person_info"); + AssertJsonPropertyEquals(jsonSchema, "strict", true); + AssertJsonPropertyExists(jsonSchema, "schema"); + + var schema = jsonSchema.GetProperty("schema"); + AssertJsonPropertyEquals(schema, "type", "object"); + AssertJsonPropertyExists(schema, "properties"); + AssertJsonPropertyExists(schema, "required"); + + // Assert - Response content is valid JSON matching schema + var responseMessage = response.GetProperty("choices")[0].GetProperty("message"); + string content = responseMessage.GetProperty("content").GetString()!; + Assert.NotNull(content); + Assert.Equal(expectedText, content); + + using var jsonDoc = JsonDocument.Parse(content); + var jsonRoot = jsonDoc.RootElement; + AssertJsonPropertyExists(jsonRoot, "name"); + AssertJsonPropertyExists(jsonRoot, "age"); + AssertJsonPropertyExists(jsonRoot, "occupation"); + + Assert.Equal(JsonValueKind.String, jsonRoot.GetProperty("name").ValueKind); + Assert.Equal(JsonValueKind.Number, jsonRoot.GetProperty("age").ValueKind); + Assert.Equal(JsonValueKind.String, jsonRoot.GetProperty("occupation").ValueKind); + } + + /// + /// Helper to parse chat completion chunks from SSE response. + /// + private static List ParseChatCompletionChunksFromSse(string sseContent) + { + var chunks = new List(); + var lines = sseContent.Split('\n'); + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i].TrimEnd('\r'); + + if (line.StartsWith("data: ", System.StringComparison.Ordinal)) + { + var jsonData = line.Substring("data: ".Length); + + // Skip [DONE] marker + if (jsonData == "[DONE]") + { + continue; + } + + try + { + var doc = JsonDocument.Parse(jsonData); + chunks.Add(doc.RootElement.Clone()); + } + catch + { + // Skip invalid JSON + } + } + } + + return chunks; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsIntegrationTests.cs new file mode 100644 index 0000000000..6d02383ceb --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsIntegrationTests.cs @@ -0,0 +1,974 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OpenAI; +using OpenAI.Chat; +using ChatFinishReason = OpenAI.Chat.ChatFinishReason; +using ChatMessage = OpenAI.Chat.ChatMessage; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Integration tests that start a web server and use the OpenAI Chat Completions SDK client to verify protocol compatibility. +/// These tests validate both streaming and non-streaming request scenarios. +/// +public sealed class OpenAIChatCompletionsIntegrationTests : IAsyncDisposable +{ + private WebApplication? _app; + private HttpClient? _httpClient; + + public async ValueTask DisposeAsync() + { + this._httpClient?.Dispose(); + if (this._app != null) + { + await this._app.DisposeAsync(); + } + } + + /// + /// Verifies that streaming chat completions work correctly with the OpenAI SDK client. + /// + [Fact] + public async Task CreateChatCompletionStreaming_WithSimpleMessage_ReturnsStreamingUpdatesAsync() + { + // Arrange + const string AgentName = "streaming-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "One Two Three"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Count to 3") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + List updates = []; + StringBuilder contentBuilder = new(); + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + updates.Add(update); + if (update.ContentUpdate.Count > 0) + { + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + contentBuilder.Append(contentPart.Text); + } + } + } + + Assert.NotEmpty(updates); + + // Verify content was received + string content = contentBuilder.ToString(); + Assert.Equal(ExpectedResponse, content); + + // Verify finish reason + StreamingChatCompletionUpdate? lastUpdate = updates.LastOrDefault(u => u.FinishReason != null); + Assert.NotNull(lastUpdate); + Assert.Equal(ChatFinishReason.Stop, lastUpdate.FinishReason); + } + + /// + /// Verifies that non-streaming chat completions work correctly with the OpenAI SDK client. + /// + [Fact] + public async Task CreateChatCompletion_WithSimpleMessage_ReturnsCompleteResponseAsync() + { + // Arrange + const string AgentName = "non-streaming-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Hello! How can I help you today?"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Hello") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + Assert.NotNull(completion); + Assert.NotNull(completion.Id); + Assert.StartsWith("chatcmpl-", completion.Id); + Assert.Equal(ChatFinishReason.Stop, completion.FinishReason); + + // Verify content + string content = completion.Content[0].Text; + Assert.Equal(ExpectedResponse, content); + } + + /// + /// Verifies that streaming chat completions can handle multiple content chunks. + /// + [Fact] + public async Task CreateChatCompletionStreaming_WithMultipleChunks_StreamsAllContentAsync() + { + // Arrange + const string AgentName = "multi-chunk-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "This is a test response with multiple words"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + List updates = []; + StringBuilder contentBuilder = new(); + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + updates.Add(update); + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + contentBuilder.Append(contentPart.Text); + } + } + + // Verify all content was received + string receivedContent = contentBuilder.ToString(); + Assert.Equal(ExpectedResponse, receivedContent); + + // Verify multiple content chunks were received + List contentUpdates = updates.Where(u => u.ContentUpdate.Count > 0).ToList(); + Assert.True(contentUpdates.Count > 1, "Expected multiple content chunks in streaming response"); + } + + /// + /// Verifies that multiple agents can be accessed via the same server. + /// + [Fact] + public async Task CreateChatCompletion_WithMultipleAgents_EachAgentRespondsCorrectlyAsync() + { + // Arrange + const string Agent1Name = "agent-one"; + const string Agent1Instructions = "You are agent one."; + const string Agent1Response = "Response from agent one"; + + const string Agent2Name = "agent-two"; + const string Agent2Instructions = "You are agent two."; + const string Agent2Response = "Response from agent two"; + + this._httpClient = await this.CreateTestServerWithMultipleAgentsAsync( + (Agent1Name, Agent1Instructions, Agent1Response), + (Agent2Name, Agent2Instructions, Agent2Response)); + + ChatClient chatClient1 = this.CreateChatClient(Agent1Name); + ChatClient chatClient2 = this.CreateChatClient(Agent2Name); + + List messages = + [ + new UserChatMessage("Hello") + ]; + + // Act + ChatCompletion completion1 = await chatClient1.CompleteChatAsync(messages); + ChatCompletion completion2 = await chatClient2.CompleteChatAsync(messages); + + // Assert + string content1 = completion1.Content[0].Text; + string content2 = completion2.Content[0].Text; + + Assert.Equal(Agent1Response, content1); + Assert.Equal(Agent2Response, content2); + Assert.NotEqual(content1, content2); + } + + /// + /// Verifies that streaming and non-streaming work correctly for the same agent. + /// + [Fact] + public async Task CreateChatCompletion_SameAgentStreamingAndNonStreaming_BothWorkCorrectlyAsync() + { + // Arrange + const string AgentName = "dual-mode-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "This is the response"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act - Non-streaming + ChatCompletion nonStreamingCompletion = await chatClient.CompleteChatAsync(messages); + + // Act - Streaming + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + StringBuilder streamingContent = new(); + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + streamingContent.Append(contentPart.Text); + } + } + + // Assert + string nonStreamingContent = nonStreamingCompletion.Content[0].Text; + Assert.Equal(ExpectedResponse, nonStreamingContent); + Assert.Equal(ExpectedResponse, streamingContent.ToString()); + } + + /// + /// Verifies that the finish reason is correctly set for completed responses. + /// + [Fact] + public async Task CreateChatCompletion_CompletedResponse_HasCorrectFinishReasonAsync() + { + // Arrange + const string AgentName = "finish-reason-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Complete"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + Assert.Equal(ChatFinishReason.Stop, completion.FinishReason); + Assert.NotNull(completion.Id); + Assert.Equal(ExpectedResponse, completion.Content[0].Text); + } + + /// + /// Verifies that streaming responses contain the expected chunk sequence. + /// + [Fact] + public async Task CreateChatCompletionStreaming_VerifyChunkSequence_ContainsExpectedDataAsync() + { + // Arrange + const string AgentName = "chunk-sequence-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Test response with multiple words"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + List updates = []; + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + updates.Add(update); + } + + // Verify chunks received + Assert.NotEmpty(updates); + + // First chunk should have role + StreamingChatCompletionUpdate? firstUpdate = updates.FirstOrDefault(u => u.Role != null); + if (firstUpdate != null) + { + Assert.Equal(ChatMessageRole.Assistant, firstUpdate.Role); + } + + // Should contain content chunks + List contentUpdates = updates.Where(u => u.ContentUpdate.Count > 0).ToList(); + Assert.NotEmpty(contentUpdates); + + // Last update should have finish reason + StreamingChatCompletionUpdate? lastUpdate = updates.LastOrDefault(u => u.FinishReason != null); + Assert.NotNull(lastUpdate); + Assert.Equal(ChatFinishReason.Stop, lastUpdate.FinishReason); + } + + /// + /// Verifies that streaming responses properly handle empty responses. + /// + [Fact] + public async Task CreateChatCompletionStreaming_EmptyResponse_HandlesGracefullyAsync() + { + // Arrange + const string AgentName = "empty-response-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = ""; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + List updates = []; + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + updates.Add(update); + } + + // Should still receive chunks with finish reason + Assert.NotEmpty(updates); + Assert.Contains(updates, u => u.FinishReason == ChatFinishReason.Stop); + } + + /// + /// Verifies that non-streaming responses include proper metadata. + /// + [Fact] + public async Task CreateChatCompletion_IncludesMetadata_HasRequiredFieldsAsync() + { + // Arrange + const string AgentName = "metadata-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Response with metadata"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + Assert.NotNull(completion.Id); + Assert.StartsWith("chatcmpl-", completion.Id); + Assert.NotNull(completion.Model); + Assert.NotEqual(default, completion.CreatedAt); + Assert.Equal(ChatFinishReason.Stop, completion.FinishReason); + } + + /// + /// Verifies that streaming responses handle very long text correctly. + /// + [Fact] + public async Task CreateChatCompletionStreaming_LongText_StreamsAllContentAsync() + { + // Arrange + const string AgentName = "long-text-agent"; + const string Instructions = "You are a helpful assistant."; + string expectedResponse = string.Join(" ", Enumerable.Range(1, 100).Select(i => $"Word{i}")); + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, expectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Generate long text") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + StringBuilder contentBuilder = new(); + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + contentBuilder.Append(contentPart.Text); + } + } + + string receivedContent = contentBuilder.ToString(); + Assert.Equal(expectedResponse, receivedContent); + } + + /// + /// Verifies that streaming responses properly handle single-word responses. + /// + [Fact] + public async Task CreateChatCompletionStreaming_SingleWord_StreamsCorrectlyAsync() + { + // Arrange + const string AgentName = "single-word-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Hello"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + StringBuilder contentBuilder = new(); + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + contentBuilder.Append(contentPart.Text); + } + } + + Assert.Equal(ExpectedResponse, contentBuilder.ToString()); + } + + /// + /// Verifies that streaming responses preserve special characters and formatting. + /// + [Fact] + public async Task CreateChatCompletionStreaming_SpecialCharacters_PreservesFormattingAsync() + { + // Arrange + const string AgentName = "special-chars-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Hello! How are you? I'm fine. 100% great!"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + StringBuilder contentBuilder = new(); + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + contentBuilder.Append(contentPart.Text); + } + } + + Assert.Equal(ExpectedResponse, contentBuilder.ToString()); + } + + /// + /// Verifies that non-streaming responses handle special characters correctly. + /// + [Fact] + public async Task CreateChatCompletion_SpecialCharacters_PreservesContentAsync() + { + // Arrange + const string AgentName = "special-chars-nonstreaming-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Symbols: @#$%^&*() Quotes: \"Hello\" 'World' Unicode: 你好 🌍"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + string content = completion.Content[0].Text; + Assert.Equal(ExpectedResponse, content); + } + + /// + /// Verifies that multiple sequential non-streaming requests work correctly. + /// + [Fact] + public async Task CreateChatCompletion_MultipleSequentialRequests_AllSucceedAsync() + { + // Arrange + const string AgentName = "sequential-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Response"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + // Act & Assert - Make 5 sequential requests + for (int i = 0; i < 5; i++) + { + List messages = + [ + new UserChatMessage($"Request {i}") + ]; + + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + Assert.NotNull(completion); + Assert.Equal(ChatFinishReason.Stop, completion.FinishReason); + Assert.Equal(ExpectedResponse, completion.Content[0].Text); + } + } + + /// + /// Verifies that multiple sequential streaming requests work correctly. + /// + [Fact] + public async Task CreateChatCompletionStreaming_MultipleSequentialRequests_AllStreamCorrectlyAsync() + { + // Arrange + const string AgentName = "sequential-streaming-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Streaming response"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + // Act & Assert - Make 3 sequential streaming requests + for (int i = 0; i < 3; i++) + { + List messages = + [ + new UserChatMessage($"Request {i}") + ]; + + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + StringBuilder contentBuilder = new(); + + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + contentBuilder.Append(contentPart.Text); + } + } + + Assert.Equal(ExpectedResponse, contentBuilder.ToString()); + } + } + + /// + /// Verifies that completion IDs are unique across multiple requests. + /// + [Fact] + public async Task CreateChatCompletion_MultipleRequests_GenerateUniqueIdsAsync() + { + // Arrange + const string AgentName = "unique-id-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Response"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + // Act + List completionIds = []; + for (int i = 0; i < 10; i++) + { + List messages = + [ + new UserChatMessage($"Request {i}") + ]; + + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + completionIds.Add(completion.Id); + } + + // Assert + Assert.Equal(10, completionIds.Count); + Assert.Equal(completionIds.Count, completionIds.Distinct().Count()); // All IDs should be unique + } + + /// + /// Verifies that streaming responses all have the same ID within a single request. + /// + [Fact] + public async Task CreateChatCompletionStreaming_SameRequestId_ConsistentAcrossChunksAsync() + { + // Arrange + const string AgentName = "consistent-id-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Test consistent ID across chunks"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + List chunkIds = []; + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + if (!string.IsNullOrEmpty(update.CompletionId)) + { + chunkIds.Add(update.CompletionId); + } + } + + // All chunk IDs should be the same within a single request + Assert.NotEmpty(chunkIds); + Assert.All(chunkIds, id => Assert.Equal(chunkIds[0], id)); + Assert.StartsWith("chatcmpl-", chunkIds[0]); + } + + /// + /// Verifies that non-streaming responses work with system messages. + /// + [Fact] + public async Task CreateChatCompletion_WithSystemMessage_ReturnsValidResponseAsync() + { + // Arrange + const string AgentName = "system-message-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "I am following the system instructions"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new SystemChatMessage("You must respond in a specific way"), + new UserChatMessage("Hello") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + Assert.NotNull(completion); + Assert.Equal(ChatFinishReason.Stop, completion.FinishReason); + Assert.Equal(ExpectedResponse, completion.Content[0].Text); + } + + /// + /// Verifies that responses handle newlines correctly. + /// + [Fact] + public async Task CreateChatCompletion_Newlines_PreservesFormattingAsync() + { + // Arrange + const string AgentName = "newline-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Line 1\nLine 2\nLine 3"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + string content = completion.Content[0].Text; + Assert.Equal(ExpectedResponse, content); + Assert.Contains("\n", content); + } + + /// + /// Verifies that streaming responses handle newlines correctly. + /// + [Fact] + public async Task CreateChatCompletionStreaming_Newlines_PreservesFormattingAsync() + { + // Arrange + const string AgentName = "newline-streaming-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "First line\nSecond line\nThird line"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + AsyncCollectionResult streamingResult = chatClient.CompleteChatStreamingAsync(messages); + + // Assert + StringBuilder contentBuilder = new(); + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + foreach (ChatMessageContentPart contentPart in update.ContentUpdate) + { + contentBuilder.Append(contentPart.Text); + } + } + + string content = contentBuilder.ToString(); + Assert.Equal(ExpectedResponse, content); + Assert.Contains("\n", content); + } + + /// + /// Verifies that responses with conversation history work correctly. + /// + [Fact] + public async Task CreateChatCompletion_WithConversationHistory_ReturnsValidResponseAsync() + { + // Arrange + const string AgentName = "conversation-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "3 plus 3 equals 6"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("What is 2+2?"), + new AssistantChatMessage("2+2 equals 4"), + new UserChatMessage("What about 3+3?") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + Assert.NotNull(completion); + Assert.Equal(ChatFinishReason.Stop, completion.FinishReason); + Assert.Equal(ExpectedResponse, completion.Content[0].Text); + } + + /// + /// Verifies that usage information is included in non-streaming responses. + /// + [Fact] + public async Task CreateChatCompletion_IncludesUsage_HasTokenCountsAsync() + { + // Arrange + const string AgentName = "usage-agent"; + const string Instructions = "You are a helpful assistant."; + const string ExpectedResponse = "Response with usage information"; + + this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse); + ChatClient chatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Test") + ]; + + // Act + ChatCompletion completion = await chatClient.CompleteChatAsync(messages); + + // Assert + Assert.NotNull(completion.Usage); + Assert.True(completion.Usage.InputTokenCount > 0); + Assert.True(completion.Usage.OutputTokenCount > 0); + Assert.Equal(completion.Usage.InputTokenCount + completion.Usage.OutputTokenCount, completion.Usage.TotalTokenCount); + } + + /// + /// Verifies that responses with function calls work correctly. + /// + [Fact] + public async Task CreateChatCompletion_WithFunctionCall_ReturnsToolCallsAsync() + { + // Arrange + const string AgentName = "function-call-agent"; + const string Instructions = "You are a helpful assistant."; + const string FunctionName = "get_weather"; + const string Arguments = "{\"location\":\"Seattle\"}"; + + this._httpClient = await this.CreateTestServerWithCustomClientAsync( + agentName: AgentName, + instructions: Instructions, + chatClient: new TestHelpers.FunctionCallMockChatClient(FunctionName, Arguments)); + + ChatClient openAIChatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("What's the weather?") + ]; + + // Act + ChatCompletion completion = await openAIChatClient.CompleteChatAsync(messages); + + // Assert + Assert.NotNull(completion); + Assert.Equal(ChatFinishReason.ToolCalls, completion.FinishReason); + Assert.NotNull(completion.ToolCalls); + Assert.NotEmpty(completion.ToolCalls); + + ChatToolCall toolCall = completion.ToolCalls[0]; + Assert.Equal(FunctionName, toolCall.FunctionName); + Assert.NotNull(toolCall.FunctionArguments); + } + + /// + /// Verifies that streaming responses with function calls work correctly. + /// + [Fact] + public async Task CreateChatCompletionStreaming_WithFunctionCall_StreamsToolCallsAsync() + { + // Arrange + const string AgentName = "function-call-streaming-agent"; + const string Instructions = "You are a helpful assistant."; + const string FunctionName = "calculate"; + const string Arguments = "{\"expression\":\"2+2\"}"; + + this._httpClient = await this.CreateTestServerWithCustomClientAsync( + agentName: AgentName, + instructions: Instructions, + chatClient: new TestHelpers.FunctionCallMockChatClient(FunctionName, Arguments)); + + ChatClient openAIChatClient = this.CreateChatClient(AgentName); + + List messages = + [ + new UserChatMessage("Calculate 2+2") + ]; + + // Act + AsyncCollectionResult streamingResult = openAIChatClient.CompleteChatStreamingAsync(messages); + + // Assert + List updates = []; + await foreach (StreamingChatCompletionUpdate update in streamingResult) + { + updates.Add(update); + } + + Assert.NotEmpty(updates); + + // Should have finish reason of tool_calls + StreamingChatCompletionUpdate? lastUpdate = updates.LastOrDefault(u => u.FinishReason != null); + Assert.NotNull(lastUpdate); + Assert.True(lastUpdate.FinishReason is ChatFinishReason.ToolCalls or ChatFinishReason.Stop); // depends on what response we get + } + + private ChatClient CreateChatClient(string agentName) + { + return new ChatClient( + model: "test-model", + credential: new ApiKeyCredential("test-api-key"), + options: new OpenAIClientOptions + { + Endpoint = new Uri(this._httpClient!.BaseAddress!, $"/{agentName}/v1/"), + Transport = new HttpClientPipelineTransport(this._httpClient) + }); + } + + private async Task CreateTestServerAsync(string agentName, string instructions, string responseText = "Test response") + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient(responseText); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddOpenAIChatCompletions(); + builder.AddAIAgent(agentName, instructions, chatClientServiceKey: "chat-client"); + + this._app = builder.Build(); + AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); + this._app.MapOpenAIChatCompletions(agent); + + await this._app.StartAsync(); + + TestServer testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + return testServer.CreateClient(); + } + + private async Task CreateTestServerWithCustomClientAsync(string agentName, string instructions, IChatClient chatClient) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddKeyedSingleton($"chat-client-{agentName}", chatClient); + builder.AddAIAgent(agentName, instructions, chatClientServiceKey: $"chat-client-{agentName}"); + builder.AddOpenAIChatCompletions(); + + this._app = builder.Build(); + AIAgent agent = this._app.Services.GetRequiredKeyedService(agentName); + this._app.MapOpenAIChatCompletions(agent); + + await this._app.StartAsync(); + + TestServer testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + return testServer.CreateClient(); + } + + private async Task CreateTestServerWithMultipleAgentsAsync( + params (string Name, string Instructions, string ResponseText)[] agents) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + foreach ((string name, string instructions, string responseText) in agents) + { + IChatClient mockChatClient = new TestHelpers.SimpleMockChatClient(responseText); + builder.Services.AddKeyedSingleton($"chat-client-{name}", mockChatClient); + builder.AddAIAgent(name, instructions, chatClientServiceKey: $"chat-client-{name}"); + } + + builder.AddOpenAIChatCompletions(); + + this._app = builder.Build(); + + foreach ((string name, string _, string _) in agents) + { + AIAgent agent = this._app.Services.GetRequiredKeyedService(name); + this._app.MapOpenAIChatCompletions(agent); + } + + await this._app.StartAsync(); + + TestServer testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + return testServer.CreateClient(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsSerializationTests.cs new file mode 100644 index 0000000000..edf11fbf6b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIChatCompletionsSerializationTests.cs @@ -0,0 +1,576 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models; +using Microsoft.Agents.AI.Hosting.OpenAI.Tests; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Tests for OpenAI ChatCompletions API model serialization and deserialization. +/// These tests verify that our models correctly serialize to and deserialize from JSON +/// matching the OpenAI wire format, without testing actual API implementation behavior. +/// +public sealed class OpenAIChatCompletionsSerializationTests : ConformanceTestBase +{ + #region Request Deserialization Tests + + [Fact] + public void Deserialize_BasicRequest_Success() + { + // Arrange + string json = LoadChatCompletionsTraceFile("basic/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.Equal("gpt-4o-mini", request.Model); + Assert.NotNull(request.Messages); + Assert.True(request.Messages.Count > 0); + Assert.Equal(100, request.MaxCompletionTokens); + } + + [Fact] + public void Deserialize_BasicRequest_RoundTrip() + { + // Arrange + string originalJson = LoadChatCompletionsTraceFile("basic/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(originalJson, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + string reserializedJson = JsonSerializer.Serialize(request, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + CreateChatCompletion? roundtripped = JsonSerializer.Deserialize(reserializedJson, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.NotNull(roundtripped); + Assert.Equal(request.Model, roundtripped.Model); + Assert.Equal(request.MaxCompletionTokens, roundtripped.MaxCompletionTokens); + Assert.Equal(request.Messages.Count, roundtripped.Messages.Count); + } + + [Fact] + public void Deserialize_BasicRequest_HasMessages() + { + // Arrange + string json = LoadChatCompletionsTraceFile("basic/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.NotNull(request.Messages); + Assert.Single(request.Messages); + + var message = request.Messages[0]; + Assert.Equal("user", message.Role); + Assert.NotNull(message.Content); + } + + [Fact] + public void Deserialize_StreamingRequest_HasStreamFlag() + { + // Arrange + string json = LoadChatCompletionsTraceFile("streaming/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.True(request.Stream); + Assert.Equal(150, request.MaxCompletionTokens); + } + + [Fact] + public void Deserialize_SystemMessageRequest_HasSystemRole() + { + // Arrange + string json = LoadChatCompletionsTraceFile("system_message/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.NotNull(request.Messages); + Assert.True(request.Messages.Count >= 2); + Assert.Equal("system", request.Messages[0].Role); + Assert.Equal("user", request.Messages[1].Role); + } + + [Fact] + public void Deserialize_MultiTurnRequest_HasMultipleMessages() + { + // Arrange + string json = LoadChatCompletionsTraceFile("multi_turn/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.NotNull(request.Messages); + Assert.True(request.Messages.Count >= 3); + Assert.Equal("user", request.Messages[0].Role); + Assert.Equal("assistant", request.Messages[1].Role); + Assert.Equal("user", request.Messages[2].Role); + } + + [Fact] + public void Deserialize_FunctionCallingRequest_HasTools() + { + // Arrange + string json = LoadChatCompletionsTraceFile("function_calling/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.NotNull(request.Tools); + Assert.Single(request.Tools); + Assert.NotNull(request.ToolChoice?.Mode); + Assert.Equal("auto", request.ToolChoice.Mode); + } + + [Fact] + public void Deserialize_JsonModeRequest_HasResponseFormat() + { + // Arrange + string json = LoadChatCompletionsTraceFile("json_mode/request.json"); + + // Act + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + + // Assert + Assert.NotNull(request); + Assert.NotNull(request.ResponseFormat); + } + + [Fact] + public void Deserialize_AllRequests_CanBeDeserialized() + { + // Arrange + string[] requestPaths = + [ + "basic/request.json", + "streaming/request.json", + "system_message/request.json", + "multi_turn/request.json", + "function_calling/request.json", + "json_mode/request.json" + ]; + + foreach (var path in requestPaths) + { + string json = LoadChatCompletionsTraceFile(path); + + // Act & Assert - Should not throw + CreateChatCompletion? request = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.CreateChatCompletion); + Assert.NotNull(request); + Assert.NotNull(request.Messages); + Assert.True(request.Messages.Count > 0, $"Request from {path} should have messages"); + } + } + + #endregion + + #region Response Deserialization Tests + + [Fact] + public void Deserialize_BasicResponse_Success() + { + // Arrange + string json = LoadChatCompletionsTraceFile("basic/response.json"); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.StartsWith("chatcmpl-", response.Id); + Assert.Equal("chat.completion", response.Object); + Assert.True(response.Created > 0); + Assert.NotNull(response.Model); + Assert.StartsWith("gpt-4o-mini", response.Model); + } + + [Fact] + public void Deserialize_BasicResponse_HasChoices() + { + // Arrange + string json = LoadChatCompletionsTraceFile("basic/response.json"); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Choices); + Assert.Single(response.Choices); + + var choice = response.Choices[0]; + Assert.Equal(0, choice.Index); + Assert.NotNull(choice.Message); + Assert.Equal("assistant", choice.Message.Role); + Assert.NotNull(choice.Message.Content); + Assert.NotNull(choice.FinishReason); + } + + [Fact] + public void Deserialize_BasicResponse_HasUsage() + { + // Arrange + string json = LoadChatCompletionsTraceFile("basic/response.json"); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Usage); + Assert.True(response.Usage.PromptTokens > 0); + Assert.True(response.Usage.CompletionTokens > 0); + Assert.Equal(response.Usage.PromptTokens + response.Usage.CompletionTokens, response.Usage.TotalTokens); + Assert.NotNull(response.Usage.PromptTokensDetails); + Assert.NotNull(response.Usage.CompletionTokensDetails); + } + + [Fact] + public void Deserialize_SystemMessageResponse_HasContent() + { + // Arrange + string json = LoadChatCompletionsTraceFile("system_message/response.json"); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Choices); + var message = response.Choices[0].Message; + Assert.Equal("assistant", message.Role); + Assert.NotNull(message.Content); + Assert.Contains("Ahoy, matey", message.Content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Deserialize_MultiTurnResponse_HasContent() + { + // Arrange + string json = LoadChatCompletionsTraceFile("multi_turn/response.json"); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Choices); + var message = response.Choices[0].Message; + Assert.Equal("assistant", message.Role); + Assert.NotNull(message.Content); + } + + [Fact] + public void Deserialize_FunctionCallingResponse_HasToolCalls() + { + // Arrange + string json = LoadChatCompletionsTraceFile("function_calling/response.json"); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Choices); + + var choice = response.Choices[0]; + Assert.Equal("tool_calls", choice.FinishReason); + + var message = choice.Message; + Assert.NotNull(message.ToolCalls); + Assert.Single(message.ToolCalls); + + var toolCall = message.ToolCalls[0]; + Assert.NotNull(toolCall.Id); + Assert.StartsWith("call_", toolCall.Id); + Assert.Equal("function", toolCall.Type); + Assert.NotNull(toolCall.Function); + Assert.Equal("get_weather", toolCall.Function.Name); + Assert.NotNull(toolCall.Function.Arguments); + } + + [Fact] + public void Deserialize_JsonModeResponse_HasStructuredOutput() + { + // Arrange + string json = LoadChatCompletionsTraceFile("json_mode/response.json"); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Choices); + + var message = response.Choices[0].Message; + Assert.NotNull(message.Content); + + // Verify the content is valid JSON + using var jsonDoc = JsonDocument.Parse(message.Content); + var jsonRoot = jsonDoc.RootElement; + Assert.Equal(JsonValueKind.Object, jsonRoot.ValueKind); + Assert.True(jsonRoot.TryGetProperty("name", out _)); + Assert.True(jsonRoot.TryGetProperty("age", out _)); + Assert.True(jsonRoot.TryGetProperty("occupation", out _)); + } + + [Fact] + public void Deserialize_AllResponses_HaveRequiredFields() + { + // Arrange + string[] responsePaths = + [ + "basic/response.json", + "system_message/response.json", + "multi_turn/response.json", + "function_calling/response.json", + "json_mode/response.json" + ]; + + foreach (var path in responsePaths) + { + string json = LoadChatCompletionsTraceFile(path); + + // Act + ChatCompletion? response = JsonSerializer.Deserialize(json, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Id); + Assert.Equal("chat.completion", response.Object); + Assert.True(response.Created > 0, $"Response from {path} should have created timestamp"); + Assert.NotNull(response.Model); + Assert.NotNull(response.Choices); + Assert.True(response.Choices.Count > 0, $"Response from {path} should have choices"); + } + } + + [Fact] + public void Deserialize_ResponseRoundTrip_PreservesData() + { + // Arrange + string originalJson = LoadChatCompletionsTraceFile("basic/response.json"); + + // Act - Deserialize and re-serialize + ChatCompletion? response = JsonSerializer.Deserialize(originalJson, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + string reserializedJson = JsonSerializer.Serialize(response, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + ChatCompletion? roundtripped = JsonSerializer.Deserialize(reserializedJson, ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletion); + + // Assert + Assert.NotNull(response); + Assert.NotNull(roundtripped); + Assert.Equal(response.Id, roundtripped.Id); + Assert.Equal(response.Created, roundtripped.Created); + Assert.Equal(response.Model, roundtripped.Model); + Assert.Equal(response.Choices.Count, roundtripped.Choices.Count); + } + + #endregion + + #region Streaming Chunk Deserialization Tests + + [Fact] + public void ParseStreamingChunks_BasicFormat_Success() + { + // Arrange + string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt"); + + // Act + var chunks = ParseChatCompletionChunksFromSse(sseContent); + + // Assert + Assert.NotEmpty(chunks); + Assert.All(chunks, chunk => + { + ChatCompletionChunk? parsed = JsonSerializer.Deserialize(chunk.GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk); + Assert.NotNull(parsed); + Assert.NotNull(parsed.Id); + Assert.Equal("chat.completion.chunk", parsed.Object); + Assert.True(parsed.Created > 0); + Assert.NotNull(parsed.Model); + Assert.NotNull(parsed.Choices); + }); + } + + [Fact] + public void ParseStreamingChunks_AllChunksSameId() + { + // Arrange + string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt"); + + // Act + var chunks = ParseChatCompletionChunksFromSse(sseContent); + + // Deserialize chunks + var parsedChunks = chunks + .Select(c => JsonSerializer.Deserialize(c.GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk)) + .Where(c => c != null) + .ToList(); + + // Assert + Assert.NotEmpty(parsedChunks); + + string? firstId = parsedChunks[0]!.Id; + Assert.NotNull(firstId); + Assert.StartsWith("chatcmpl-", firstId); + + Assert.All(parsedChunks, chunk => Assert.Equal(firstId, chunk!.Id)); + } + + [Fact] + public void ParseStreamingChunks_FirstChunkHasRole() + { + // Arrange + string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt"); + + // Act + var chunks = ParseChatCompletionChunksFromSse(sseContent); + var firstChunk = JsonSerializer.Deserialize(chunks[0].GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk); + + // Assert + Assert.NotNull(firstChunk); + Assert.NotNull(firstChunk.Choices); + Assert.True(firstChunk.Choices.Count > 0); + + var firstChoice = firstChunk.Choices[0]; + Assert.NotNull(firstChoice.Delta); + + if (firstChoice.Delta.Role != null) + { + Assert.Equal("assistant", firstChoice.Delta.Role); + } + } + + [Fact] + public void ParseStreamingChunks_AccumulateContent_MatchesExpected() + { + // Arrange + string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt"); + + // Act + var chunks = ParseChatCompletionChunksFromSse(sseContent); + var contentPieces = new List(); + + foreach (var chunkJson in chunks) + { + var chunk = JsonSerializer.Deserialize(chunkJson.GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk); + if (chunk?.Choices != null && chunk.Choices.Count > 0) + { + var delta = chunk.Choices[0].Delta; + if (!string.IsNullOrEmpty(delta?.Content)) + { + contentPieces.Add(delta.Content); + } + } + } + + // Assert + Assert.NotEmpty(contentPieces); + string fullText = string.Concat(contentPieces); + Assert.NotEmpty(fullText); + Assert.Contains("circuits", fullText); + Assert.Contains("flight", fullText); + } + + [Fact] + public void ParseStreamingChunks_LastChunkHasFinishReason() + { + // Arrange + string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt"); + + // Act + var chunks = ParseChatCompletionChunksFromSse(sseContent); + + // Find chunks with finish_reason + var chunksWithFinishReason = new List(); + foreach (var chunkJson in chunks) + { + var chunk = JsonSerializer.Deserialize(chunkJson.GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk); + if (chunk?.Choices != null && chunk.Choices.Count > 0 && !string.IsNullOrEmpty(chunk.Choices[0].FinishReason)) + { + chunksWithFinishReason.Add(chunk); + } + } + + // Assert + Assert.NotEmpty(chunksWithFinishReason); + var lastChunk = chunksWithFinishReason.Last(); + Assert.Contains(lastChunk.Choices[0].FinishReason, collection: ["stop", "length", "tool_calls", "content_filter"]); + } + + [Fact] + public void ParseStreamingChunks_LastChunkHasUsage() + { + // Arrange + string sseContent = LoadChatCompletionsTraceFile("streaming/response.txt"); + + // Act + var chunks = ParseChatCompletionChunksFromSse(sseContent); + var lastChunkJson = chunks.Last(); + var lastChunk = JsonSerializer.Deserialize(lastChunkJson.GetRawText(), ChatCompletions.ChatCompletionsJsonContext.Default.ChatCompletionChunk); + + // Assert + Assert.NotNull(lastChunk); + Assert.NotNull(lastChunk.Usage); + Assert.True(lastChunk.Usage.PromptTokens > 0); + Assert.True(lastChunk.Usage.CompletionTokens > 0); + Assert.Equal(lastChunk.Usage.PromptTokens + lastChunk.Usage.CompletionTokens, lastChunk.Usage.TotalTokens); + } + + /// + /// Helper to parse chat completion chunks from SSE response. + /// + private static List ParseChatCompletionChunksFromSse(string sseContent) + { + var chunks = new List(); + var lines = sseContent.Split('\n'); + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i].TrimEnd('\r'); + + if (line.StartsWith("data: ", StringComparison.Ordinal)) + { + var jsonData = line.Substring("data: ".Length); + + // Skip [DONE] marker + if (jsonData == "[DONE]") + { + continue; + } + + try + { + var doc = JsonDocument.Parse(jsonData); + chunks.Add(doc.RootElement.Clone()); + } + catch + { + // Skip invalid JSON + } + } + } + + return chunks; + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesConformanceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesConformanceTests.cs index de05ea666d..547920c0fa 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesConformanceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesConformanceTests.cs @@ -22,8 +22,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task BasicRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("basic/request.json"); - using var expectedResponseDoc = LoadTraceDocument("basic/response.json"); + string requestJson = LoadResponsesTraceFile("basic/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("basic/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get the expected response text from the trace to use as mock response @@ -34,7 +34,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("basic-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "basic-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "basic-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -160,8 +160,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task ConversationRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("conversation/request.json"); - using var expectedResponseDoc = LoadTraceDocument("conversation/response.json"); + string requestJson = LoadResponsesTraceFile("conversation/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("conversation/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get the expected response text @@ -172,7 +172,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("conversation-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "conversation-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "conversation-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -270,8 +270,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task ToolCallRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("tool_call/request.json"); - using var expectedResponseDoc = LoadTraceDocument("tool_call/response.json"); + string requestJson = LoadResponsesTraceFile("tool_call/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("tool_call/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get function call details from expected response @@ -282,7 +282,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("tool-agent", "You are a helpful assistant.", functionName); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "tool-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "tool-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -429,8 +429,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task StreamingRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedResponseSse = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedResponseSse = LoadResponsesTraceFile("streaming/response.txt"); // Extract expected text from SSE events var expectedEvents = ParseSseEventsFromContent(expectedResponseSse); @@ -440,7 +440,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-agent", requestJson); // Assert - Response should be SSE format Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType); @@ -634,8 +634,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task MetadataRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("metadata/request.json"); - using var expectedResponseDoc = LoadTraceDocument("metadata/response.json"); + string requestJson = LoadResponsesTraceFile("metadata/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("metadata/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get expected text (truncated due to max_output_tokens) @@ -646,7 +646,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("metadata-agent", "Respond in a friendly, educational tone.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "metadata-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "metadata-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -761,8 +761,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task ReasoningRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("reasoning/request.json"); - using var expectedResponseDoc = LoadTraceDocument("reasoning/response.json"); + string requestJson = LoadResponsesTraceFile("reasoning/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("reasoning/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get expected text from the message output @@ -773,7 +773,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("reasoning-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "reasoning-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "reasoning-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -843,8 +843,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task JsonOutputRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("json_output/request.json"); - using var expectedResponseDoc = LoadTraceDocument("json_output/response.json"); + string requestJson = LoadResponsesTraceFile("json_output/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("json_output/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get expected JSON text from response @@ -855,7 +855,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("json-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "json-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "json-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -927,8 +927,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task RefusalRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("refusal/request.json"); - using var expectedResponseDoc = LoadTraceDocument("refusal/response.json"); + string requestJson = LoadResponsesTraceFile("refusal/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("refusal/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get expected refusal text @@ -939,7 +939,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("refusal-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "refusal-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "refusal-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -986,8 +986,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task ImageInputRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("image_input/request.json"); - using var expectedResponseDoc = LoadTraceDocument("image_input/response.json"); + string requestJson = LoadResponsesTraceFile("image_input/request.json"); + using var expectedResponseDoc = LoadResponsesTraceDocument("image_input/response.json"); var expectedResponse = expectedResponseDoc.RootElement; // Get expected text @@ -998,7 +998,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("image-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "image-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "image-agent", requestJson); using var responseDoc = await ParseResponseAsync(httpResponse); var response = responseDoc.RootElement; @@ -1059,8 +1059,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task ReasoningStreamingRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("reasoning_streaming/request.json"); - string expectedResponseSse = LoadTraceFile("reasoning_streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("reasoning_streaming/request.json"); + string expectedResponseSse = LoadResponsesTraceFile("reasoning_streaming/response.txt"); // Extract expected text from SSE events var expectedEvents = ParseSseEventsFromContent(expectedResponseSse); @@ -1070,7 +1070,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("reasoning-streaming-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "reasoning-streaming-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "reasoning-streaming-agent", requestJson); // Assert - Response should be SSE format Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType); @@ -1137,8 +1137,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task JsonOutputStreamingRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("json_output_streaming/request.json"); - string expectedResponseSse = LoadTraceFile("json_output_streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("json_output_streaming/request.json"); + string expectedResponseSse = LoadResponsesTraceFile("json_output_streaming/response.txt"); // Extract expected text from SSE events var expectedEvents = ParseSseEventsFromContent(expectedResponseSse); @@ -1148,7 +1148,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("json-streaming-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "json-streaming-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "json-streaming-agent", requestJson); // Assert - Response should be SSE format Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType); @@ -1197,8 +1197,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task RefusalStreamingRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("refusal_streaming/request.json"); - string expectedResponseSse = LoadTraceFile("refusal_streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("refusal_streaming/request.json"); + string expectedResponseSse = LoadResponsesTraceFile("refusal_streaming/response.txt"); // Extract expected text from SSE events var expectedEvents = ParseSseEventsFromContent(expectedResponseSse); @@ -1208,7 +1208,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("refusal-streaming-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "refusal-streaming-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "refusal-streaming-agent", requestJson); // Assert - Response should be SSE format Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType); @@ -1254,8 +1254,8 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase public async Task ImageInputStreamingRequestResponseAsync() { // Arrange - string requestJson = LoadTraceFile("image_input_streaming/request.json"); - string expectedResponseSse = LoadTraceFile("image_input_streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("image_input_streaming/request.json"); + string expectedResponseSse = LoadResponsesTraceFile("image_input_streaming/response.txt"); // Extract expected text from SSE events var expectedEvents = ParseSseEventsFromContent(expectedResponseSse); @@ -1265,7 +1265,7 @@ public sealed class OpenAIResponsesConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("image-streaming-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "image-streaming-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "image-streaming-agent", requestJson); // Assert - Response should be SSE format Assert.Equal("text/event-stream", httpResponse.Content.Headers.ContentType?.MediaType); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesSerializationTests.cs index d487450248..08823e4494 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesSerializationTests.cs @@ -22,7 +22,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_BasicRequest_Success() { // Arrange - string json = LoadTraceFile("basic/request.json"); + string json = LoadResponsesTraceFile("basic/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -38,7 +38,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_BasicRequest_RoundTrip() { // Arrange - string originalJson = LoadTraceFile("basic/request.json"); + string originalJson = LoadResponsesTraceFile("basic/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(originalJson, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -56,7 +56,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_StreamingRequest_HasStreamFlag() { // Arrange - string json = LoadTraceFile("streaming/request.json"); + string json = LoadResponsesTraceFile("streaming/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -71,7 +71,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ConversationRequest_HasPreviousResponseId() { // Arrange - string json = LoadTraceFile("conversation/request.json"); + string json = LoadResponsesTraceFile("conversation/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -86,7 +86,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_MetadataRequest_HasAllParameters() { // Arrange - string json = LoadTraceFile("metadata/request.json"); + string json = LoadResponsesTraceFile("metadata/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -111,7 +111,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ToolCallRequest_HasToolDefinitions() { // Arrange - string json = LoadTraceFile("tool_call/request.json"); + string json = LoadResponsesTraceFile("tool_call/request.json"); // Act // CreateResponse doesn't have Tools property - it uses dynamic JSON @@ -220,7 +220,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ImageInputRequest_HasImageData() { // Arrange - string json = LoadTraceFile("image_input/request.json"); + string json = LoadResponsesTraceFile("image_input/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -234,7 +234,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ImageInputStreamingRequest_HasStreamAndImage() { // Arrange - string json = LoadTraceFile("image_input_streaming/request.json"); + string json = LoadResponsesTraceFile("image_input_streaming/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -249,7 +249,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_JsonOutputRequest_HasJsonSchema() { // Arrange - string json = LoadTraceFile("json_output/request.json"); + string json = LoadResponsesTraceFile("json_output/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -270,7 +270,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_JsonOutputStreamingRequest_HasJsonSchemaAndStream() { // Arrange - string json = LoadTraceFile("json_output_streaming/request.json"); + string json = LoadResponsesTraceFile("json_output_streaming/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -290,7 +290,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ReasoningRequest_HasReasoningConfiguration() { // Arrange - string json = LoadTraceFile("reasoning/request.json"); + string json = LoadResponsesTraceFile("reasoning/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -304,7 +304,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ReasoningStreamingRequest_HasReasoningAndStream() { // Arrange - string json = LoadTraceFile("reasoning_streaming/request.json"); + string json = LoadResponsesTraceFile("reasoning_streaming/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -319,7 +319,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_RefusalRequest_CanBeDeserialized() { // Arrange - string json = LoadTraceFile("refusal/request.json"); + string json = LoadResponsesTraceFile("refusal/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -333,7 +333,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_RefusalStreamingRequest_HasStream() { // Arrange - string json = LoadTraceFile("refusal_streaming/request.json"); + string json = LoadResponsesTraceFile("refusal_streaming/request.json"); // Act CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -367,7 +367,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase foreach (var path in requestPaths) { - string json = LoadTraceFile(path); + string json = LoadResponsesTraceFile(path); // Act & Assert - Should not throw CreateResponse? request = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.CreateResponse); @@ -384,7 +384,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_BasicResponse_Success() { // Arrange - string json = LoadTraceFile("basic/response.json"); + string json = LoadResponsesTraceFile("basic/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -403,7 +403,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_BasicResponse_HasCorrectOutput() { // Arrange - string json = LoadTraceFile("basic/response.json"); + string json = LoadResponsesTraceFile("basic/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -426,7 +426,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_BasicResponse_HasCorrectUsage() { // Arrange - string json = LoadTraceFile("basic/response.json"); + string json = LoadResponsesTraceFile("basic/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -445,7 +445,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ConversationResponse_HasPreviousResponseId() { // Arrange - string json = LoadTraceFile("conversation/response.json"); + string json = LoadResponsesTraceFile("conversation/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -461,7 +461,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_MetadataResponse_PreservesMetadata() { // Arrange - string json = LoadTraceFile("metadata/response.json"); + string json = LoadResponsesTraceFile("metadata/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -478,7 +478,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_MetadataResponse_HasIncompleteStatus() { // Arrange - string json = LoadTraceFile("metadata/response.json"); + string json = LoadResponsesTraceFile("metadata/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -494,7 +494,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_MetadataResponse_HasInstructions() { // Arrange - string json = LoadTraceFile("metadata/response.json"); + string json = LoadResponsesTraceFile("metadata/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -509,7 +509,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_MetadataResponse_HasModelParameters() { // Arrange - string json = LoadTraceFile("metadata/response.json"); + string json = LoadResponsesTraceFile("metadata/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -525,7 +525,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ToolCallResponse_HasFunctionCall() { // Arrange - string json = LoadTraceFile("tool_call/response.json"); + string json = LoadResponsesTraceFile("tool_call/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -549,7 +549,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ToolCallResponse_HasToolDefinitions() { // Arrange - string json = LoadTraceFile("tool_call/response.json"); + string json = LoadResponsesTraceFile("tool_call/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -573,7 +573,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ImageInputResponse_HasImageInInput() { // Arrange - string json = LoadTraceFile("image_input/response.json"); + string json = LoadResponsesTraceFile("image_input/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -588,7 +588,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_JsonOutputResponse_HasStructuredOutput() { // Arrange - string json = LoadTraceFile("json_output/response.json"); + string json = LoadResponsesTraceFile("json_output/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -608,7 +608,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ReasoningResponse_HasReasoningItems() { // Arrange - string json = LoadTraceFile("reasoning/response.json"); + string json = LoadResponsesTraceFile("reasoning/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -624,7 +624,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_RefusalResponse_HasRefusalContent() { // Arrange - string json = LoadTraceFile("refusal/response.json"); + string json = LoadResponsesTraceFile("refusal/response.json"); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -653,7 +653,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase foreach (var path in responsePaths) { - string json = LoadTraceFile(path); + string json = LoadResponsesTraceFile(path); // Act Response? response = JsonSerializer.Deserialize(json, Responses.ResponsesJsonContext.Default.Response); @@ -672,7 +672,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void Deserialize_ResponseRoundTrip_PreservesData() { // Arrange - string originalJson = LoadTraceFile("basic/response.json"); + string originalJson = LoadResponsesTraceFile("basic/response.json"); // Act - Deserialize and re-serialize Response? response = JsonSerializer.Deserialize(originalJson, Responses.ResponsesJsonContext.Default.Response); @@ -696,7 +696,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_BasicFormat_Success() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); // Act var events = ParseSseEventsFromContent(sseContent); @@ -715,7 +715,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_HasCorrectEventTypes() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); // Act var events = ParseSseEventsFromContent(sseContent); @@ -736,7 +736,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_DeserializeCreatedEvent_Success() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); var createdEventJson = events.First(e => e.GetProperty("type").GetString() == "response.created"); @@ -758,7 +758,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_DeserializeInProgressEvent_Success() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); var inProgressEventJson = events.First(e => e.GetProperty("type").GetString() == "response.in_progress"); @@ -779,7 +779,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_DeserializeOutputItemAdded_Success() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); var itemAddedJson = events.First(e => e.GetProperty("type").GetString() == "response.output_item.added"); @@ -799,7 +799,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_DeserializeContentPartAdded_Success() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); var partAddedJson = events.First(e => e.GetProperty("type").GetString() == "response.content_part.added"); @@ -821,7 +821,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_DeserializeTextDelta_Success() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); var textDeltaJson = events.First(e => e.GetProperty("type").GetString() == "response.output_text.delta"); @@ -843,7 +843,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_AccumulateTextDeltas_MatchesFinalText() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); // Act @@ -877,7 +877,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_SequenceNumbersAreSequential() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); // Act @@ -904,7 +904,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_FinalEvent_IsTerminalState() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); var events = ParseSseEventsFromContent(sseContent); var lastEventJson = events.Last(); @@ -926,7 +926,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_ImageInputStreaming_HasImageEvents() { // Arrange - string sseContent = LoadTraceFile("image_input_streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("image_input_streaming/response.txt"); // Act var events = ParseSseEventsFromContent(sseContent); @@ -944,7 +944,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_JsonOutputStreaming_HasJsonSchemaEvents() { // Arrange - string sseContent = LoadTraceFile("json_output_streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("json_output_streaming/response.txt"); // Act var events = ParseSseEventsFromContent(sseContent); @@ -962,7 +962,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_ReasoningStreaming_HasReasoningEvents() { // Arrange - string sseContent = LoadTraceFile("reasoning_streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("reasoning_streaming/response.txt"); // Act var events = ParseSseEventsFromContent(sseContent); @@ -983,7 +983,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_RefusalStreaming_HasRefusalEvents() { // Arrange - string sseContent = LoadTraceFile("refusal_streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("refusal_streaming/response.txt"); // Act var events = ParseSseEventsFromContent(sseContent); @@ -1014,7 +1014,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase foreach (var path in streamingPaths) { - string sseContent = LoadTraceFile(path); + string sseContent = LoadResponsesTraceFile(path); // Act & Assert foreach (var eventJson in ParseSseEventsFromContent(sseContent)) @@ -1030,7 +1030,7 @@ public sealed class OpenAIResponsesSerializationTests : ConformanceTestBase public void ParseStreamingEvents_AllEvents_CanBeDeserialized() { // Arrange - string sseContent = LoadTraceFile("streaming/response.txt"); + string sseContent = LoadResponsesTraceFile("streaming/response.txt"); // Act & Assert foreach (var eventJson in ParseSseEventsFromContent(sseContent)) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/StreamingEventConformanceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/StreamingEventConformanceTests.cs index 22e3838a67..573b3ad26a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/StreamingEventConformanceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/StreamingEventConformanceTests.cs @@ -24,8 +24,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_BasicFormat_SuccessAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); // Extract expected text var expectedEvents = ParseSseEvents(expectedSseContent); @@ -35,7 +35,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-basic-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-basic-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-basic-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); // Act @@ -55,8 +55,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_HasCorrectEventTypesAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -65,7 +65,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-types-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-types-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-types-agent", requestJson); // Assert - HTTP response validation Assert.Equal(System.Net.HttpStatusCode.OK, httpResponse.StatusCode); @@ -118,8 +118,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_DeserializeCreatedEvent_SuccessAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -128,7 +128,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-created-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-created-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-created-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); var createdEventJson = events.First(e => e.GetProperty("type").GetString() == "response.created"); @@ -151,8 +151,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_DeserializeInProgressEvent_SuccessAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -161,7 +161,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-progress-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-progress-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-progress-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); var inProgressEventJson = events.First(e => e.GetProperty("type").GetString() == "response.in_progress"); @@ -183,8 +183,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_DeserializeOutputItemAdded_SuccessAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -193,7 +193,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-item-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-item-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-item-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); var itemAddedJson = events.First(e => e.GetProperty("type").GetString() == "response.output_item.added"); @@ -214,8 +214,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_DeserializeContentPartAdded_SuccessAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -224,7 +224,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-part-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-part-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-part-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); var partAddedJson = events.First(e => e.GetProperty("type").GetString() == "response.content_part.added"); @@ -247,8 +247,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_DeserializeTextDelta_SuccessAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -257,7 +257,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-delta-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-delta-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-delta-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); var textDeltaJson = events.First(e => e.GetProperty("type").GetString() == "response.output_text.delta"); @@ -280,8 +280,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_AccumulateTextDeltas_MatchesFinalTextAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -290,7 +290,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-accumulate-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-accumulate-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-accumulate-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -325,8 +325,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_SequenceNumbersAreSequentialAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -335,7 +335,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-sequence-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-sequence-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-sequence-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -363,8 +363,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_FinalEvent_IsTerminalStateAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -373,7 +373,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-terminal-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-terminal-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-terminal-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); var lastEventJson = events.Last(); @@ -396,8 +396,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_AllEvents_CanBeDeserializedAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -406,7 +406,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-deserialize-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-deserialize-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-deserialize-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); // Act & Assert @@ -439,8 +439,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_IdConsistency_ValidAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -449,7 +449,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-id-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-id-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-id-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -530,8 +530,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_IndexConsistency_ValidAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -540,7 +540,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-index-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-index-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-index-agent", requestJson); // Assert - All events with output_index should have valid values foreach (var eventJson in ParseSseEvents(await httpResponse.Content.ReadAsStringAsync())) @@ -587,8 +587,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_ResponseObjectEvolution_ValidAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -597,7 +597,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-evolution-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-evolution-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-evolution-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -655,8 +655,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_SseFormatCompliance_ValidAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -665,7 +665,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-sse-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-sse-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-sse-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); // Assert - SSE format validation @@ -699,8 +699,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_EventPairing_ValidAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -709,7 +709,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-pairing-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-pairing-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-pairing-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent); @@ -755,8 +755,8 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase public async Task ParseStreamingEvents_NoDuplicateSequenceNumbers_ValidAsync() { // Arrange - string requestJson = LoadTraceFile("streaming/request.json"); - string expectedSseContent = LoadTraceFile("streaming/response.txt"); + string requestJson = LoadResponsesTraceFile("streaming/request.json"); + string expectedSseContent = LoadResponsesTraceFile("streaming/response.txt"); var expectedEvents = ParseSseEvents(expectedSseContent); var deltaEvents = expectedEvents.Where(e => e.GetProperty("type").GetString() == "response.output_text.delta").ToList(); @@ -765,7 +765,7 @@ public sealed class StreamingEventConformanceTests : ConformanceTestBase HttpClient client = await this.CreateTestServerAsync("streaming-nodup-agent", "You are a helpful assistant.", expectedText); // Act - HttpResponseMessage httpResponse = await this.SendRequestAsync(client, "streaming-nodup-agent", requestJson); + HttpResponseMessage httpResponse = await this.SendResponsesRequestAsync(client, "streaming-nodup-agent", requestJson); string sseContent = await httpResponse.Content.ReadAsStringAsync(); var events = ParseSseEvents(sseContent);