.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
This commit is contained in:
Korolev Dmitry
2025-11-05 09:55:26 +00:00
committed by GitHub
parent 54db13c22f
commit bb8ef466de
46 changed files with 5687 additions and 406 deletions
@@ -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<IChatClient>("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");
@@ -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<IResult> CreateChatCompletionAsync(AIAgent agent, CreateChatCompletion request, CancellationToken cancellationToken)
{
this._agent = agent;
}
ArgumentNullException.ThrowIfNull(agent);
public async Task<IResult> 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<ChatCompletion>;
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<ChatMessage> chatMessages) : IResult
private sealed class StreamingResponse(
AIAgent agent,
CreateChatCompletion request,
IEnumerable<ChatMessage> chatMessages,
ChatClientAgentRunOptions? options) : IResult
{
public Task ExecuteAsync(HttpContext httpContext)
{
@@ -79,26 +53,99 @@ internal sealed class AIAgentChatCompletionsProcessor
httpContext.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
return SseFormatter.WriteAsync(
source: this.GetStreamingResponsesAsync(cancellationToken),
source: this.GetStreamingChunksAsync(cancellationToken),
destination: response.Body,
itemFormatter: (sseItem, bufferWriter) =>
{
var sseDataJsonModel = (IJsonModel<StreamingChatCompletionUpdate>)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<SseItem<StreamingChatCompletionUpdate>> GetStreamingResponsesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
private async IAsyncEnumerable<SseItem<ChatCompletionChunk>> 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>(streamingChatCompletionUpdate);
var finishReason = (agentRunResponseUpdate.RawRepresentation is ChatResponseUpdate { FinishReason: not null } chatResponseUpdate)
? chatResponseUpdate.FinishReason.ToString()
: "stop";
var choiceChunks = new List<ChatCompletionChoiceChunk>();
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);
}
}
}
@@ -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;
/// <summary>
/// Extension methods for converting agent responses to ChatCompletion models.
/// </summary>
internal static class AgentRunResponseExtensions
{
public static ChatCompletion ToChatCompletion(this AgentRunResponse agentRunResponse, CreateChatCompletion request)
{
IList<ChatCompletionChoice> 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<ChatCompletionChoice> ToChoices(this AgentRunResponse agentRunResponse)
{
var chatCompletionChoices = new List<ChatCompletionChoice>();
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;
}
/// <summary>
/// Converts UsageDetails to CompletionUsage.
/// </summary>
/// <param name="usage">The usage details to convert.</param>
/// <returns>A CompletionUsage object with zeros if usage is null.</returns>
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<ChoiceMessageAnnotation> ToChoiceMessageAnnotations(this IList<AIAnnotation> annotations)
{
var result = new List<ChoiceMessageAnnotation>();
foreach (var annotation in annotations.OfType<CitationAnnotation>())
{
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<TextSpanAnnotatedRegion>().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)
}
};
}
}
@@ -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<string, string>))]
[JsonSerializable(typeof(CreateChatCompletion))]
[JsonSerializable(typeof(StopSequences))]
[JsonSerializable(typeof(ChatCompletion))]
[JsonSerializable(typeof(ChatCompletionRequestMessage))]
[JsonSerializable(typeof(IList<ChatCompletionRequestMessage>))]
[JsonSerializable(typeof(MessageContent))]
[JsonSerializable(typeof(MessageContentPart))]
[JsonSerializable(typeof(IReadOnlyList<MessageContentPart>))]
[JsonSerializable(typeof(TextContentPart))]
[JsonSerializable(typeof(ImageContentPart))]
[JsonSerializable(typeof(AudioContentPart))]
[JsonSerializable(typeof(FileContentPart))]
[JsonSerializable(typeof(ChatCompletionChoice))]
[JsonSerializable(typeof(IList<ChatCompletionChoice>))]
[JsonSerializable(typeof(ChoiceMessage))]
[JsonSerializable(typeof(ChoiceMessageAnnotation))]
[JsonSerializable(typeof(ChoiceMessageAudio))]
[JsonSerializable(typeof(ChoiceMessageFunctionCall))]
[JsonSerializable(typeof(ChoiceMessageToolCall))]
[JsonSerializable(typeof(AnnotationUrlCitation))]
[JsonSerializable(typeof(ChatCompletionChoiceChunk))]
[JsonSerializable(typeof(IList<ChatCompletionChoiceChunk>))]
[JsonSerializable(typeof(ChatCompletionChunk))]
[JsonSerializable(typeof(ChatCompletionDelta))]
[JsonSerializable(typeof(ToolChoice))]
[JsonSerializable(typeof(AllowedToolsChoice))]
[JsonSerializable(typeof(AllowedToolsConfiguration))]
[JsonSerializable(typeof(ToolDefinition))]
[JsonSerializable(typeof(IList<ToolDefinition>))]
[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<Tool>))]
[JsonSerializable(typeof(FunctionTool))]
[JsonSerializable(typeof(FunctionDefinition))]
[JsonSerializable(typeof(CustomTool))]
[JsonSerializable(typeof(CustomToolProperties))]
[JsonSerializable(typeof(CustomToolFormat))]
[ExcludeFromCodeCoverage]
internal sealed partial class ChatCompletionsJsonContext : JsonSerializerContext;
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
/// <summary>
/// Extension methods for JSON serialization.
/// </summary>
internal static class ChatCompletionsJsonSerializerOptions
{
/// <summary>
/// Gets the default JSON serializer options.
/// </summary>
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;
}
}
@@ -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));
}
}
@@ -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/*";
}
}
@@ -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;
/// <summary>
/// Represents a chat completion response returned by the model, based on the provided input.
/// </summary>
internal sealed record ChatCompletion
{
/// <summary>
/// A unique identifier for the chat completion.
/// </summary>
[JsonPropertyName("id")]
[JsonRequired]
public required string Id { get; init; }
/// <summary>
/// The object type, which is always "chat.completion".
/// </summary>
[JsonPropertyName("object")]
public string Object { get; init; } = "chat.completion";
/// <summary>
/// The Unix timestamp (in seconds) of when the chat completion was created.
/// </summary>
[JsonPropertyName("created")]
[JsonRequired]
public required long Created { get; init; }
/// <summary>
/// The model used for the chat completion.
/// </summary>
[JsonPropertyName("model")]
[JsonRequired]
public required string Model { get; init; }
/// <summary>
/// A list of chat completion choices. Can be more than one if n is greater than 1.
/// </summary>
[JsonPropertyName("choices")]
[JsonRequired]
public required IList<ChatCompletionChoice> Choices { get; init; }
/// <summary>
/// Usage statistics for the completion request.
/// </summary>
[JsonPropertyName("usage")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public CompletionUsage? Usage { get; init; }
/// <summary>
/// The service tier used for processing the request. This field is only included if the service_tier parameter is specified in the request.
/// </summary>
[JsonPropertyName("service_tier")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ServiceTier { get; init; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("system_fingerprint")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SystemFingerprint { get; init; }
}
@@ -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;
/// <summary>
/// Represents a choice in a chat completion response.
/// </summary>
internal sealed record ChatCompletionChoice
{
/// <summary>
/// The index of the choice in the list of choices.
/// </summary>
[JsonPropertyName("index")]
public required int Index { get; init; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("finish_reason")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? FinishReason { get; init; }
/// <summary>
/// A chat completion message generated by the model.
/// </summary>
[JsonPropertyName("message")]
public required ChoiceMessage Message { get; init; }
}
/// <summary>
/// A chat completion message generated by the model.
/// </summary>
internal sealed record ChoiceMessage
{
/// <summary>
/// The role of the author of this message.
/// </summary>
[JsonPropertyName("role")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Role { get; set; }
/// <summary>
/// A list of annotations for this message. Currently used for web search citations.
/// </summary>
[JsonPropertyName("annotations")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<ChoiceMessageAnnotation>? Annotations { get; set; }
/// <summary>
/// The contents of the message.
/// </summary>
[JsonPropertyName("content")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Content { get; set; }
/// <summary>
/// The refusal message generated by the model.
/// </summary>
[JsonPropertyName("refusal")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Refusal { get; set; }
/// <summary>
/// If the audio output modality is requested, this object contains data about the audio response from the model.
/// </summary>
[JsonPropertyName("audio")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ChoiceMessageAudio? Audio { get; set; }
/// <summary>
/// Deprecated and replaced by tool_calls. The name and arguments of a function that should be called, as generated by the model.
/// </summary>
[JsonPropertyName("function_call")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ChoiceMessageFunctionCall? FunctionCall { get; set; }
/// <summary>
/// The tool calls generated by the model, such as function calls.
/// </summary>
[JsonPropertyName("tool_calls")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<ChoiceMessageToolCall>? ToolCalls { get; set; }
}
/// <summary>
/// Audio output data in a chat completion message.
/// </summary>
internal sealed record ChoiceMessageAudio
{
/// <summary>
/// Base64 encoded audio bytes generated by the model, in the format specified in the request.
/// </summary>
[JsonPropertyName("data")]
public string? Data { get; init; }
/// <summary>
/// The Unix timestamp (in seconds) for when this audio response will no longer be accessible on the server for use in multi-turn conversations.
/// </summary>
[JsonPropertyName("expires_at")]
public int ExpiresAt { get; init; }
/// <summary>
/// Unique identifier for this audio response.
/// </summary>
[JsonPropertyName("id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Id { get; init; }
/// <summary>
/// Transcript of the audio generated by the model.
/// </summary>
[JsonPropertyName("transcript")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Transcript { get; init; }
}
/// <summary>
/// Deprecated. The name and arguments of a function that should be called, as generated by the model.
/// </summary>
internal sealed record ChoiceMessageFunctionCall
{
/// <summary>
/// The name of the function to call.
/// </summary>
[JsonPropertyName("name")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Name { get; init; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("arguments")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Arguments { get; init; }
}
/// <summary>
/// Represents a tool call generated by the model.
/// </summary>
internal sealed record ChoiceMessageToolCall
{
/// <summary>
/// The ID of the tool call.
/// </summary>
[JsonPropertyName("id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Id { get; init; }
/// <summary>
/// The type of the tool.
/// </summary>
public string Type => "function";
/// <summary>
/// The function that the model called.
/// </summary>
[JsonPropertyName("function")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ChoiceMessageFunctionCall? Function { get; set; }
}
/// <summary>
/// An annotation for a message, used for web search citations.
/// </summary>
internal sealed record ChoiceMessageAnnotation
{
/// <summary>
/// The type of annotation. Always 'url_citation' for web search results.
/// </summary>
[JsonPropertyName("type")]
public string Type => "url_citation";
/// <summary>
/// The URL citation details.
/// </summary>
[JsonPropertyName("url_citation")]
public required AnnotationUrlCitation AnnotationUrlCitation { get; init; }
}
/// <summary>
/// A citation to a URL for a web search result.
/// </summary>
internal sealed record AnnotationUrlCitation
{
/// <summary>
/// The character index in the message content where the citation ends.
/// </summary>
[JsonPropertyName("end_index")]
public int? EndIndex { get; init; }
/// <summary>
/// The character index in the message content where the citation starts.
/// </summary>
[JsonPropertyName("start_index")]
public int? StartIndex { get; init; }
/// <summary>
/// The title of the cited resource.
/// </summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>
/// The URL of the cited resource.
/// </summary>
[JsonPropertyName("url")]
public string? Url { get; set; }
}
@@ -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;
/// <summary>
/// Represents a chunk of chat completion response returned by the model, based on the provided input.
/// </summary>
internal sealed record ChatCompletionChunk
{
/// <summary>
/// A unique identifier for the chat completion. Each chunk has the same ID.
/// </summary>
[JsonPropertyName("id")]
[JsonRequired]
public required string Id { get; init; }
/// <summary>
/// A list of chat completion choices. Can be more than one if n is greater than 1.
/// </summary>
[JsonPropertyName("choices")]
[JsonRequired]
public required IList<ChatCompletionChoiceChunk> Choices { get; init; }
/// <summary>
/// The object type, which is always "chat.completion.chunk".
/// </summary>
[JsonPropertyName("object")]
public string Object => "chat.completion.chunk";
/// <summary>
/// The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same timestamp.
/// </summary>
[JsonPropertyName("created")]
[JsonRequired]
public required long Created { get; init; }
/// <summary>
/// The model to generate the completion.
/// </summary>
[JsonPropertyName("model")]
[JsonRequired]
public required string Model { get; init; }
/// <summary>
/// Usage statistics for the completion request.
/// </summary>
[JsonPropertyName("usage")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public CompletionUsage? Usage { get; init; }
/// <summary>
/// The service tier used for processing the request. This field is only included if the service_tier parameter is specified in the request.
/// </summary>
[JsonPropertyName("service_tier")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ServiceTier { get; init; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("system_fingerprint")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SystemFingerprint { get; init; }
}
internal sealed record ChatCompletionChoiceChunk
{
/// <summary>
/// The index of the choice in the list of choices.
/// </summary>
[JsonPropertyName("index")]
public required int Index { get; init; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("finish_reason")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? FinishReason { get; init; }
[JsonPropertyName("delta")]
public required ChatCompletionDelta Delta { get; init; }
}
internal sealed record ChatCompletionDelta
{
/// <summary>
/// The contents of the chunk message.
/// </summary>
[JsonPropertyName("content")]
public string? Content { get; init; }
/// <summary>
/// The refusal message generated by the model.
/// </summary>
[JsonPropertyName("refusal")]
public string? Refusal { get; init; }
/// <summary>
/// The role of the author of this message.
/// </summary>
[JsonPropertyName("role")]
public string? Role { get; set; }
/// <summary>
/// Deprecated and replaced by tool_calls. The name and arguments of a function that should be called, as generated by the model.
/// </summary>
[JsonPropertyName("function_call")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ChoiceMessageFunctionCall? FunctionCall { get; set; }
[JsonPropertyName("tool_calls")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<ChoiceMessageToolCall>? ToolCalls { get; set; }
}
@@ -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;
/// <summary>
/// Represents a message in a chat completion request.
/// </summary>
[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
{
/// <summary>
/// The role of the content.
/// </summary>
[JsonIgnore]
public abstract string Role { get; }
/// <summary>
/// The contents of the message.
/// </summary>
[JsonPropertyName("content")]
public required MessageContent Content { get; init; }
/// <summary>
/// Converts to a <see cref="ChatMessage"/>.
/// </summary>
/// <returns>A <see cref="ChatMessage"/> representing the message.</returns>
/// <exception cref="InvalidOperationException">Thrown when the content is neither text nor AI contents.</exception>
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");
}
}
/// <summary>
/// A developer message in a chat completion request.
/// Developer messages are used to provide instructions to the model at the system level.
/// </summary>
internal sealed record DeveloperMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "developer";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// A system message in a chat completion request.
/// System messages provide high-level instructions for the conversation.
/// </summary>
internal sealed record SystemMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "system";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// A user message in a chat completion request.
/// User messages represent input from the end user.
/// </summary>
internal sealed record UserMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "user";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// An assistant message in a chat completion request.
/// Assistant messages represent previous responses from the model, used in multi-turn conversations.
/// </summary>
internal sealed record AssistantMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "assistant";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// A tool message in a chat completion request.
/// Tool messages contain the result of a tool call made by the assistant.
/// </summary>
internal sealed record ToolMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "tool";
/// <summary>
/// Tool call that this message is responding to.
/// </summary>
[JsonPropertyName("tool_call_id")]
public required string ToolCallId { get; set; }
}
/// <summary>
/// Deprecated. A function message in a chat completion request.
/// Function messages have been replaced by tool messages.
/// </summary>
internal sealed record FunctionMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "function";
/// <summary>
/// The name of the function to call.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Converts to a <see cref="ChatMessage"/>.
/// </summary>
/// <returns>A <see cref="ChatMessage"/> representing the message.</returns>
/// <exception cref="InvalidOperationException">Thrown when the content is not text.</exception>
public override ChatMessage ToChatMessage()
{
if (this.Content.IsText)
{
return new(ChatRole.User, this.Content.Text);
}
throw new InvalidOperationException("FunctionMessage Content must be text");
}
}
@@ -0,0 +1,133 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents usage statistics for a chat completion request.
/// </summary>
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
},
};
/// <summary>
/// Number of tokens in the generated completion.
/// </summary>
[JsonPropertyName("completion_tokens")]
public int? CompletionTokens { get; set; }
/// <summary>
/// Number of tokens in the prompt.
/// </summary>
[JsonPropertyName("prompt_tokens")]
public int? PromptTokens { get; set; }
/// <summary>
/// Total number of tokens used in the request (prompt + completion).
/// </summary>
[JsonPropertyName("total_tokens")]
public int? TotalTokens { get; set; }
/// <summary>
/// Breakdown of tokens used in the generated completion.
/// </summary>
[JsonPropertyName("completion_tokens_details")]
public required CompletionTokensDetails CompletionTokensDetails { get; set; }
/// <summary>
/// Breakdown of tokens used in the prompt.
/// </summary>
[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
};
}
/// <summary>
/// Breakdown of tokens used in a completion.
/// </summary>
internal sealed record CompletionTokensDetails
{
/// <summary>
/// When using Predicted Outputs, the number of tokens in the prediction that appeared in the completion.
/// </summary>
[JsonPropertyName("accepted_prediction_tokens")]
public int AcceptedPredictionTokens { get; set; }
/// <summary>
/// Audio input tokens generated by the model.
/// </summary>
[JsonPropertyName("audio_tokens")]
public int AudioTokens { get; set; }
/// <summary>
/// Tokens generated by the model for reasoning.
/// </summary>
[JsonPropertyName("reasoning_tokens")]
public int ReasoningTokens { get; set; }
/// <summary>
/// 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.
/// </summary>
[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
};
}
/// <summary>
/// Breakdown of tokens used in the prompt.
/// </summary>
internal sealed record PromptTokensDetails
{
/// <summary>
/// Audio input tokens present in the prompt.
/// </summary>
[JsonPropertyName("audio_tokens")]
public int AudioTokens { get; set; }
/// <summary>
/// Cached tokens present in the prompt.
/// </summary>
[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
};
}
@@ -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;
/// <summary>
/// Request to create a chat completion.
/// </summary>
internal sealed record CreateChatCompletion
{
/// <summary>
/// A list of messages comprising the conversation so far.
/// </summary>
[JsonPropertyName("messages")]
[JsonRequired]
public required IList<ChatCompletionRequestMessage> Messages { get; set; }
/// <summary>
/// Model ID used to generate the response, like `gpt-4o` or `o3`.
/// </summary>
[JsonPropertyName("model")]
[JsonRequired]
public required string Model { get; set; }
/// <summary>
/// Parameters for audio output. Required when audio output is requested with modalities: ["audio"].
/// </summary>
[JsonPropertyName("audio")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? Audio { get; set; }
/// <summary>
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far.
/// </summary>
[JsonPropertyName("frequency_penalty")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? FrequencyPenalty { get; set; }
/// <summary>
/// Deprecated in favor of tool_choice. Controls which (if any) function is called by the model.
/// </summary>
[JsonPropertyName("function_call")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Obsolete("Deprecated in favor of ToolChoice.")]
public object? FunctionCall { get; set; }
/// <summary>
/// Deprecated in favor of tools. A list of functions the model may generate JSON inputs for.
/// </summary>
[JsonPropertyName("functions")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Obsolete("Deprecated in favor of Tools.")]
public IList<object>? Functions { get; set; }
/// <summary>
/// Modify the likelihood of specified tokens appearing in the completion.
/// </summary>
[JsonPropertyName("logit_bias")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, int>? LogitBias { get; set; }
/// <summary>
/// Whether to return log probabilities of the output tokens or not.
/// </summary>
[JsonPropertyName("logprobs")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Logprobs { get; set; }
/// <summary>
/// An upper bound for the number of tokens that can be generated for a completion, including visible output tokens and reasoning tokens.
/// </summary>
[JsonPropertyName("max_completion_tokens")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? MaxCompletionTokens { get; set; }
/// <summary>
/// The maximum number of tokens that can be generated in the chat completion. (Deprecated in favor of max_completion_tokens)
/// </summary>
[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; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("metadata")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, string>? Metadata { get; set; }
/// <summary>
/// Types of content modalities the model can output. Can include "text" and/or "audio".
/// </summary>
[JsonPropertyName("modalities")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<string>? Modalities { get; set; }
/// <summary>
/// How many chat completion choices to generate for each input message.
/// </summary>
[JsonPropertyName("n")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? N { get; set; }
/// <summary>
/// Whether to enable parallel function calling during tool use.
/// </summary>
[JsonPropertyName("parallel_tool_calls")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? ParallelToolCalls { get; set; }
/// <summary>
/// Configuration for a Predicted Output, which can greatly improve response times when large parts of the model response are known ahead of time.
/// </summary>
[JsonPropertyName("prediction")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? Prediction { get; set; }
/// <summary>
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far.
/// </summary>
[JsonPropertyName("presence_penalty")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? PresencePenalty { get; set; }
/// <summary>
/// Used by OpenAI to cache responses for similar requests to optimize your cache hit rates.
/// </summary>
[JsonPropertyName("prompt_cache_key")]
public string? PromptCacheKey { get; init; }
/// <summary>
/// The reasoning effort level for o-series models. Can be "low", "medium", or "high".
/// </summary>
[JsonPropertyName("reasoning_effort")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ReasoningEffort { get; set; }
/// <summary>
/// An object specifying the format that the model must output.
/// </summary>
[JsonPropertyName("response_format")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ResponseFormat? ResponseFormat { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("safety_identifier")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SafetyIdentifier { get; set; }
/// <summary>
/// If specified, the system will make a best effort to sample deterministically.
/// </summary>
[JsonPropertyName("seed")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? Seed { get; set; }
/// <summary>
/// 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'.
/// </summary>
[JsonPropertyName("service_tier")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ServiceTier { get; set; }
/// <summary>
/// Up to 4 sequences where the API will stop generating further tokens.
/// </summary>
[JsonPropertyName("stop")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public StopSequences? Stop { get; set; }
/// <summary>
/// Whether or not to store the output of this chat completion request for use in model distillation or evals products.
/// </summary>
[JsonPropertyName("store")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Store { get; set; }
/// <summary>
/// If set to true, the model response data will be streamed to the client using server-sent events.
/// </summary>
[JsonPropertyName("stream")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Stream { get; set; }
/// <summary>
/// Options for streaming response. Only set this when you set stream: true.
/// </summary>
[JsonPropertyName("stream_options")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? StreamOptions { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("temperature")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? Temperature { get; set; }
/// <summary>
/// Controls which (if any) tool is called by the model.
/// </summary>
[JsonPropertyName("tool_choice")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ToolChoice? ToolChoice { get; set; }
/// <summary>
/// A list of tools the model may call. Can include custom tools or function tools.
/// </summary>
[JsonPropertyName("tools")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<Tool>? Tools { get; set; }
/// <summary>
/// An integer between 0 and 20 specifying the number of most likely tokens to return at each token position.
/// </summary>
[JsonPropertyName("top_logprobs")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? TopLogprobs { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("top_p")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? TopP { get; set; }
/// <summary>
/// Level of detail in the model's output. Can be "standard" or "verbose".
/// </summary>
[JsonPropertyName("verbosity")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Verbosity { get; set; } = "medium";
/// <summary>
/// Web search tool configuration for searching the web for relevant results.
/// </summary>
[JsonPropertyName("web_search_options")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? WebSearchOptions { get; set; }
}
@@ -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;
/// <summary>
/// Content which is a part of <see cref="ChatCompletionRequestMessage"/>.
/// Can be either a string, or a list of content parts
/// </summary>
[JsonConverter(typeof(MessageContentJsonConverter))]
internal sealed record MessageContent : IEquatable<MessageContent>
{
private MessageContent(string text)
{
this.Text = text ?? throw new ArgumentNullException(nameof(text));
this.Contents = null;
}
private MessageContent(IReadOnlyList<MessageContentPart> contents)
{
this.Contents = contents ?? throw new ArgumentNullException(nameof(contents));
this.Text = null;
}
/// <summary>
/// Creates an MessageContent from a text string.
/// </summary>
public static MessageContent FromText(string text) => new(text);
/// <summary>
/// Creates an MessageContent from a list of MessageContentPart items.
/// </summary>
public static MessageContent FromContents(IReadOnlyList<MessageContentPart> contents) => new(contents);
/// <summary>
/// Creates an MessageContent from a list of MessageContentPart items.
/// </summary>
public static MessageContent FromContents(params MessageContentPart[] contents) => new(contents);
/// <summary>
/// Implicit conversion from string to MessageContent.
/// </summary>
public static implicit operator MessageContent(string text) => FromText(text);
/// <summary>
/// Implicit conversion from List to MessageContent.
/// </summary>
public static implicit operator MessageContent(List<MessageContentPart> contents) => FromContents(contents);
/// <summary>
/// Gets whether this content is text.
/// </summary>
[MemberNotNullWhen(true, nameof(Text))]
public bool IsText => this.Text is not null;
/// <summary>
/// Gets whether this content is a list of ItemContent items.
/// </summary>
[MemberNotNullWhen(true, nameof(Contents))]
public bool IsContents => this.Contents is not null;
/// <summary>
/// Gets the text value, or null if this is not text content.
/// </summary>
public string? Text { get; }
/// <summary>
/// Gets the ItemContent items, or null if this is not a content list.
/// </summary>
public IReadOnlyList<MessageContentPart>? Contents { get; }
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
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;
}
}
/// <summary>
/// JSON converter for <see cref="MessageContent"/>.
/// </summary>
internal sealed class MessageContentJsonConverter : JsonConverter<MessageContent>
{
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");
}
}
}
@@ -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;
/// <summary>
/// Represents a part of message content in a chat completion request.
/// Message content can be text, images, audio, or files.
/// </summary>
[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
{
/// <summary>
/// The type of the content.
/// </summary>
[JsonIgnore]
public abstract string Type { get; }
}
/// <summary>
/// A text content part in a message.
/// </summary>
internal sealed record TextContentPart : MessageContentPart
{
/// <inheritdoc />
[JsonIgnore]
public override string Type => "text";
/// <summary>
/// The text content.
/// </summary>
[JsonPropertyName("text")]
public required string Text { get; set; }
}
/// <summary>
/// An image content part in a message.
/// </summary>
internal sealed record ImageContentPart : MessageContentPart
{
/// <inheritdoc />
[JsonIgnore]
public override string Type => "image_url";
/// <summary>
/// Details about the image URL or base64-encoded image data.
/// </summary>
[JsonPropertyName("image_url")]
public required ImageUrl ImageUrl { get; set; }
/// <summary>
/// Gets the URL or base64-encoded data of the image.
/// </summary>
[JsonIgnore]
public string UrlOrData => this.ImageUrl.Url;
/// <summary>
/// Gets the URL of the image.
/// </summary>
[JsonIgnore]
public Uri Url => new(this.ImageUrl.Url);
}
/// <summary>
/// Details about an image for vision-enabled models.
/// </summary>
internal sealed record ImageUrl
{
/// <summary>
/// Either a URL of the image or the base64 encoded image data
/// </summary>
[JsonPropertyName("url")]
public required string Url { get; set; }
/// <summary>
/// Specifies the detail level of the image
/// </summary>
[JsonPropertyName("detail")]
public string? Detail { get; set; }
}
/// <summary>
/// An audio content part in a message.
/// </summary>
internal sealed record AudioContentPart : MessageContentPart
{
/// <inheritdoc />
[JsonIgnore]
public override string Type => "input_audio";
/// <summary>
/// The input audio data.
/// </summary>
[JsonPropertyName("input_audio")]
public required InputAudio InputAudio { get; set; }
}
/// <summary>
/// Input audio data for audio-enabled models.
/// </summary>
internal sealed record InputAudio
{
/// <summary>
/// Base64 encoded audio data.
/// </summary>
[JsonPropertyName("data")]
public required string Data { get; set; }
/// <summary>
/// The format of the encoded audio data. Currently supports "wav" and "mp3".
/// </summary>
[JsonPropertyName("format")]
public required string Format { get; set; }
}
/// <summary>
/// A file content part in a message.
/// </summary>
internal sealed record FileContentPart : MessageContentPart
{
/// <inheritdoc />
[JsonIgnore]
public override string Type => "file";
/// <summary>
/// The input file data.
/// </summary>
[JsonPropertyName("file")]
public required InputFile File { get; set; }
}
/// <summary>
/// Input file data for file-enabled models.
/// </summary>
internal sealed record InputFile
{
/// <summary>
/// The base64 encoded file data, used when passing the file to the model as a string.
/// </summary>
[JsonPropertyName("file_data")]
public string? FileData { get; set; }
/// <summary>
/// The ID of an uploaded file to use as input.
/// </summary>
[JsonPropertyName("file_id")]
public string? FileId { get; set; }
/// <summary>
/// The name of the file, used when passing the file to the model as a string.
/// </summary>
[JsonPropertyName("filename")]
public string? Filename { get; set; }
}
@@ -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;
/// <summary>
/// Specifies the format that the model must output.
/// </summary>
[JsonConverter(typeof(ResponseFormatConverter))]
internal sealed record ResponseFormat : IEquatable<ResponseFormat>
{
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;
}
/// <summary>
/// Creates a ResponseFormat for text output (default).
/// </summary>
public static ResponseFormat FromText() => new(new TextResponseFormat());
/// <summary>
/// Creates a ResponseFormat for JSON Schema output with Structured Outputs.
/// </summary>
public static ResponseFormat FromJsonSchema(JsonSchemaResponseFormat jsonSchema) => new(jsonSchema);
/// <summary>
/// Creates a ResponseFormat for JSON object output (older JSON mode).
/// </summary>
public static ResponseFormat FromJsonObject() => new(new JsonObjectResponseFormat());
/// <summary>
/// Gets whether this is a text response format.
/// </summary>
[MemberNotNullWhen(true, nameof(Text))]
public bool IsText => this.Text is not null;
/// <summary>
/// Gets whether this is a JSON schema response format.
/// </summary>
[MemberNotNullWhen(true, nameof(JsonSchema))]
public bool IsJsonSchema => this.JsonSchema is not null;
/// <summary>
/// Gets whether this is a JSON object response format.
/// </summary>
[MemberNotNullWhen(true, nameof(JsonObject))]
public bool IsJsonObject => this.JsonObject is not null;
/// <summary>
/// Gets the text response format, or null if this is not a text format.
/// </summary>
public TextResponseFormat? Text { get; }
/// <summary>
/// Gets the JSON schema response format, or null if this is not a JSON schema format.
/// </summary>
public JsonSchemaResponseFormat? JsonSchema { get; }
/// <summary>
/// Gets the JSON object response format, or null if this is not a JSON object format.
/// </summary>
public JsonObjectResponseFormat? JsonObject { get; }
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
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;
}
}
/// <summary>
/// Text response format. Default response format used to generate text responses.
/// </summary>
internal sealed record TextResponseFormat
{
/// <summary>
/// The type of response format. Always "text".
/// </summary>
[JsonPropertyName("type")]
public string Type => "text";
}
/// <summary>
/// JSON Schema response format. Used to generate structured JSON responses with Structured Outputs.
/// </summary>
internal sealed record JsonSchemaResponseFormat
{
/// <summary>
/// The type of response format. Always "json_schema".
/// </summary>
[JsonPropertyName("type")]
public string Type => "json_schema";
/// <summary>
/// Structured Outputs configuration options, including a JSON Schema.
/// </summary>
[JsonPropertyName("json_schema")]
[JsonRequired]
public required JsonSchemaConfiguration JsonSchema { get; init; }
}
/// <summary>
/// Configuration for JSON Schema Structured Outputs.
/// </summary>
internal sealed record JsonSchemaConfiguration
{
/// <summary>
/// The name of the schema.
/// </summary>
[JsonPropertyName("name")]
[JsonRequired]
public required string Name { get; init; }
/// <summary>
/// A description of the schema.
/// </summary>
[JsonPropertyName("description")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; init; }
/// <summary>
/// The JSON Schema definition.
/// </summary>
[JsonPropertyName("schema")]
[JsonRequired]
public required JsonElement Schema { get; init; }
/// <summary>
/// Whether to enable strict schema adherence.
/// </summary>
[JsonPropertyName("strict")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Strict { get; init; }
}
/// <summary>
/// JSON object response format. An older method of generating JSON responses.
/// Using json_schema is recommended for models that support it.
/// </summary>
internal sealed record JsonObjectResponseFormat
{
/// <summary>
/// The type of response format. Always "json_object".
/// </summary>
[JsonPropertyName("type")]
public string Type => "json_object";
}
/// <summary>
/// JSON converter for <see cref="ResponseFormat"/> that handles different response format types.
/// </summary>
internal sealed class ResponseFormatConverter : JsonConverter<ResponseFormat>
{
/// <inheritdoc/>
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.");
}
/// <inheritdoc/>
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();
}
}
}
@@ -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;
/// <summary>
/// Represents stop sequences for chat completion generation.
/// Up to 4 sequences where the API will stop generating further tokens.
/// </summary>
[JsonConverter(typeof(StopSequencesConverter))]
internal sealed record StopSequences : IEquatable<StopSequences>
{
private StopSequences(string singleSequence)
{
this.SingleSequence = singleSequence ?? throw new ArgumentNullException(nameof(singleSequence));
this.Sequences = null;
}
private StopSequences(IList<string> 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;
}
/// <summary>
/// Creates a StopSequences from a single stop sequence string.
/// </summary>
public static StopSequences FromString(string sequence) => new(sequence);
/// <summary>
/// Creates a StopSequences from a list of stop sequences.
/// </summary>
public static StopSequences FromSequences(IList<string> sequences) => new(sequences);
/// <summary>
/// Implicit conversion from string to StopSequences.
/// </summary>
public static implicit operator StopSequences(string sequence) => FromString(sequence);
/// <summary>
/// Implicit conversion from string array to StopSequences.
/// </summary>
public static implicit operator StopSequences(string[] sequences) => FromSequences(sequences);
/// <summary>
/// Implicit conversion from List to StopSequences.
/// </summary>
public static implicit operator StopSequences(List<string> sequences) => FromSequences(sequences);
/// <summary>
/// Gets whether this is a single stop sequence.
/// </summary>
[MemberNotNullWhen(true, nameof(SingleSequence))]
public bool IsSingleSequence => this.SingleSequence is not null;
/// <summary>
/// Gets whether this contains multiple stop sequences.
/// </summary>
[MemberNotNullWhen(true, nameof(Sequences))]
public bool IsSequences => this.Sequences is not null;
/// <summary>
/// Gets the single stop sequence, or null if this contains multiple sequences.
/// </summary>
public string? SingleSequence { get; }
/// <summary>
/// Gets the list of stop sequences, or null if this is a single sequence.
/// </summary>
public IList<string>? Sequences { get; }
public IList<string> SequenceList =>
this.IsSingleSequence ? [this.SingleSequence] :
this.IsSequences ? this.Sequences : [];
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
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;
}
}
/// <summary>
/// JSON converter for <see cref="StopSequences"/> that handles string, array, and null representations.
/// </summary>
internal sealed class StopSequencesConverter : JsonConverter<StopSequences>
{
/// <inheritdoc/>
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.");
}
/// <inheritdoc/>
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();
}
}
}
@@ -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;
/// <summary>
/// Represents a tool that the model may call. Can be either a function tool or a custom tool.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(FunctionTool), "function")]
[JsonDerivedType(typeof(CustomTool), "custom")]
internal abstract record Tool
{
/// <summary>
/// The type of the tool.
/// </summary>
[JsonPropertyName("type")]
public abstract string Type { get; }
}
/// <summary>
/// A function tool that can be used to generate a response.
/// </summary>
internal sealed record FunctionTool : Tool
{
/// <summary>
/// The type of the tool. Always "function".
/// </summary>
[JsonPropertyName("type")]
public override string Type => "function";
/// <summary>
/// The function definition.
/// </summary>
[JsonPropertyName("function")]
[JsonRequired]
public required FunctionDefinition Function { get; init; }
}
/// <summary>
/// Definition of a function that can be called by the model.
/// </summary>
internal sealed record FunctionDefinition
{
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("name")]
[JsonRequired]
public required string Name { get; init; }
/// <summary>
/// A description of what the function does, used by the model to choose when and how to call the function.
/// </summary>
[JsonPropertyName("description")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; init; }
/// <summary>
/// The parameters the function accepts, described as a JSON Schema object.
/// Omitting parameters defines a function with an empty parameter list.
/// </summary>
[JsonPropertyName("parameters")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonElement? Parameters { get; init; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("strict")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Strict { get; init; }
}
/// <summary>
/// A custom tool that processes input using a specified format.
/// </summary>
internal sealed record CustomTool : Tool
{
/// <summary>
/// The type of the tool. Always "custom".
/// </summary>
[JsonPropertyName("type")]
public override string Type => "custom";
/// <summary>
/// Properties of the custom tool.
/// </summary>
[JsonPropertyName("custom")]
[JsonRequired]
public required CustomToolProperties Custom { get; init; }
}
/// <summary>
/// A wrapper for MEAI <see cref="AITool"/>
/// </summary>
internal sealed class CustomAITool : AITool
{
public CustomAITool(string name, string? description, IReadOnlyDictionary<string, object?>? additionalProperties)
: base()
{
this.Name = name;
this.Description = description ?? string.Empty;
this.AdditionalProperties = additionalProperties ?? new Dictionary<string, object?>();
}
public override string Name { get; }
public override string Description { get; }
public override IReadOnlyDictionary<string, object?> AdditionalProperties { get; }
}
/// <summary>
/// Properties of a custom tool.
/// </summary>
internal sealed record CustomToolProperties
{
/// <summary>
/// The name of the custom tool, used to identify it in tool calls.
/// </summary>
[JsonPropertyName("name")]
[JsonRequired]
public required string Name { get; init; }
/// <summary>
/// Optional description of the custom tool, used to provide more context.
/// </summary>
[JsonPropertyName("description")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; init; }
/// <summary>
/// The input format for the custom tool. Default is unconstrained text.
/// </summary>
[JsonPropertyName("format")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public CustomToolFormat? Format { get; init; }
}
/// <summary>
/// The input format for a custom tool.
/// </summary>
internal sealed record CustomToolFormat
{
/// <summary>
/// The type of format. Can be various schema types.
/// </summary>
[JsonPropertyName("type")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Type { get; init; }
/// <summary>
/// Additional format properties (schema definition).
/// </summary>
[JsonExtensionData]
public Dictionary<string, object?>? AdditionalProperties { get; init; }
}
@@ -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;
/// <summary>
/// Controls which (if any) tool is called by the model.
/// </summary>
[JsonConverter(typeof(ToolChoiceConverter))]
internal sealed record ToolChoice : IEquatable<ToolChoice>
{
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;
}
/// <summary>
/// Creates a ToolChoice from a mode string ("none", "auto", or "required").
/// </summary>
public static ToolChoice FromMode(string mode) => new(mode);
/// <summary>
/// Creates a ToolChoice that constrains tools to a pre-defined set.
/// </summary>
public static ToolChoice FromAllowedTools(AllowedToolsChoice allowedTools) => new(allowedTools);
/// <summary>
/// Creates a ToolChoice that forces the model to call a specific function.
/// </summary>
public static ToolChoice FromFunction(FunctionToolChoice functionTool) => new(functionTool);
/// <summary>
/// Creates a ToolChoice that forces the model to call a specific custom tool.
/// </summary>
public static ToolChoice FromCustom(CustomToolChoice customTool) => new(customTool);
/// <summary>
/// Implicit conversion from string to ToolChoice.
/// </summary>
public static implicit operator ToolChoice(string mode) => FromMode(mode);
/// <summary>
/// Gets whether this is a mode string.
/// </summary>
[MemberNotNullWhen(true, nameof(Mode))]
public bool IsMode => this.Mode is not null;
/// <summary>
/// Gets whether this is an allowed tools configuration.
/// </summary>
[MemberNotNullWhen(true, nameof(AllowedTools))]
public bool IsAllowedTools => this.AllowedTools is not null;
/// <summary>
/// Gets whether this is a function tool choice.
/// </summary>
[MemberNotNullWhen(true, nameof(FunctionTool))]
public bool IsFunctionTool => this.FunctionTool is not null;
/// <summary>
/// Gets whether this is a custom tool choice.
/// </summary>
[MemberNotNullWhen(true, nameof(CustomTool))]
public bool IsCustomTool => this.CustomTool is not null;
/// <summary>
/// Gets the mode string, or null if this is not a mode.
/// </summary>
public string? Mode { get; }
/// <summary>
/// Gets the allowed tools configuration, or null if this is not an allowed tools choice.
/// </summary>
public AllowedToolsChoice? AllowedTools { get; }
/// <summary>
/// Gets the function tool choice, or null if this is not a function tool choice.
/// </summary>
public FunctionToolChoice? FunctionTool { get; }
/// <summary>
/// Gets the custom tool choice, or null if this is not a custom tool choice.
/// </summary>
public CustomToolChoice? CustomTool { get; }
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
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;
}
}
/// <summary>
/// Constrains the tools available to the model to a pre-defined set.
/// </summary>
internal sealed record AllowedToolsChoice
{
/// <summary>
/// The type of tool choice. Always "allowed_tools".
/// </summary>
[JsonPropertyName("type")]
public string Type => "allowed_tools";
/// <summary>
/// Constrains the tools available to the model to a pre-defined set.
/// </summary>
[JsonPropertyName("allowed_tools")]
[JsonRequired]
public required AllowedToolsConfiguration AllowedTools { get; init; }
}
/// <summary>
/// Configuration for allowed tools.
/// </summary>
internal sealed record AllowedToolsConfiguration
{
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("mode")]
[JsonRequired]
public required string Mode { get; init; }
/// <summary>
/// A list of tool definitions that the model should be allowed to call.
/// </summary>
[JsonPropertyName("tools")]
[JsonRequired]
public required IList<ToolDefinition> Tools { get; init; }
}
/// <summary>
/// A tool definition in the allowed tools list.
/// </summary>
internal sealed record ToolDefinition
{
/// <summary>
/// The type of tool (e.g., "function" or "custom").
/// </summary>
[JsonPropertyName("type")]
[JsonRequired]
public required string Type { get; init; }
/// <summary>
/// The function details if type is "function".
/// </summary>
[JsonPropertyName("function")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public FunctionReference? Function { get; init; }
}
/// <summary>
/// A reference to a function by name.
/// </summary>
internal sealed record FunctionReference
{
/// <summary>
/// The name of the function.
/// </summary>
[JsonPropertyName("name")]
[JsonRequired]
public required string Name { get; init; }
}
/// <summary>
/// Specifies a function tool the model should use.
/// </summary>
internal sealed record FunctionToolChoice
{
/// <summary>
/// The type of tool. Always "function".
/// </summary>
[JsonPropertyName("type")]
public string Type => "function";
/// <summary>
/// The function to call.
/// </summary>
[JsonPropertyName("function")]
[JsonRequired]
public required FunctionReference Function { get; init; }
}
/// <summary>
/// Specifies a custom tool the model should use.
/// </summary>
internal sealed record CustomToolChoice
{
/// <summary>
/// The type of tool. Always "custom".
/// </summary>
[JsonPropertyName("type")]
public string Type => "custom";
/// <summary>
/// The custom tool configuration.
/// </summary>
[JsonPropertyName("custom")]
[JsonRequired]
public required CustomToolObject Custom { get; init; }
}
/// <summary>
/// A reference to a custom tool object.
/// </summary>
internal sealed record CustomToolObject
{
/// <summary>
/// The name of the function.
/// </summary>
[JsonPropertyName("name")]
[JsonRequired]
public required string Name { get; init; }
}
/// <summary>
/// JSON converter for <see cref="ToolChoice"/> that handles string and object representations.
/// </summary>
internal sealed class ToolChoiceConverter : JsonConverter<ToolChoice>
{
/// <inheritdoc/>
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.");
}
/// <inheritdoc/>
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();
}
}
}
@@ -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<ChatCompletionOptions, bool?> s_getStreamNullable;
private static readonly Func<ChatCompletionOptions, IList<ChatMessage>> 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<Func<ChatCompletionOptions, bool?>>();
// --- Messages (internal IList<OpenAI.Chat.ChatMessage> 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<Func<ChatCompletionOptions, IList<ChatMessage>>>();
}
public static IList<ChatMessage> 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;
}
}
@@ -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 <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI ChatCompletions endpoints to.</param>
/// <param name="agentName">The name of the AI agent service registered in the dependency injection container. This name is used to resolve the <see cref="AIAgent"/> instance from the keyed services.</param>
/// <param name="agentBuilder">The builder for <see cref="AIAgent"/> to map the OpenAI ChatCompletions endpoints for.</param>
public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder)
=> MapOpenAIChatCompletions(endpoints, agentBuilder, path: null);
/// <summary>
/// Maps OpenAI ChatCompletions API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI ChatCompletions endpoints to.</param>
/// <param name="agentBuilder">The builder for <see cref="AIAgent"/> to map the OpenAI ChatCompletions endpoints for.</param>
/// <param name="path">Custom route path for the chat completions endpoint.</param>
public static void MapOpenAIChatCompletions(
public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path)
{
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentBuilder.Name);
return MapOpenAIChatCompletions(endpoints, agent, path);
}
/// <summary>
/// Maps OpenAI ChatCompletions API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI ChatCompletions endpoints to.</param>
/// <param name="agent">The <see cref="AIAgent"/> instance to map the OpenAI ChatCompletions endpoints for.</param>
public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, AIAgent agent)
=> MapOpenAIChatCompletions(endpoints, agent, path: null);
/// <summary>
/// Maps OpenAI ChatCompletions API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI ChatCompletions endpoints to.</param>
/// <param name="agent">The <see cref="AIAgent"/> instance to map the OpenAI ChatCompletions endpoints for.</param>
/// <param name="path">Custom route path for the chat completions endpoint.</param>
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<AIAgent>(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<ChatCompletionOptions>;
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;
}
}
@@ -8,10 +8,24 @@ using Microsoft.Extensions.Hosting;
namespace Microsoft.Extensions.Hosting;
/// <summary>
/// Extension methods for <see cref="IHostApplicationBuilder"/> to configure OpenAI Responses support.
/// Extension methods for <see cref="IHostApplicationBuilder"/> to configure OpenAI support.
/// </summary>
public static class MicrosoftAgentAIHostingOpenAIHostApplicationBuilderExtensions
{
/// <summary>
/// Adds support for exposing <see cref="AIAgent"/> instances via OpenAI ChatCompletions.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
public static IHostApplicationBuilder AddOpenAIChatCompletions(this IHostApplicationBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddOpenAIChatCompletions();
return builder;
}
/// <summary>
/// Adds support for exposing <see cref="AIAgent"/> instances via OpenAI Responses.
/// </summary>
@@ -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;
/// <summary>
/// Shared helpers to generate IDs.
/// </summary>
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
/// <summary>
/// Generates a new ID with a structured format that includes a partition key.
/// </summary>
/// <param name="prefix">The prefix to add to the ID, typically indicating the resource type.</param>
/// <param name="stringLength">The length of the random entropy string in the ID.</param>
/// <param name="partitionKeyLength">The length of the partition key if generating a new one.</param>
/// <param name="infix">Optional additional text to insert between the prefix and the entropy.</param>
/// <param name="watermark">Optional text to insert in the middle of the entropy string for traceability.</param>
/// <param name="delimiter">The delimiter character used to separate parts of the ID.</param>
/// <param name="partitionKey">An explicit partition key to use. When provided, this value will be used instead of generating a new one.</param>
/// <param name="partitionKeyHint">An existing ID to extract the partition key from. When provided, the same partition key will be used instead of generating a new one.</param>
/// <returns>A new ID with format "{prefix}{delimiter}{infix}{entropy}{delimiter}{partitionKey}".</returns>
/// <exception cref="ArgumentException">Thrown when the watermark contains non-alphanumeric characters.</exception>
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}";
}
/// <summary>
/// Generates a secure random alphanumeric string of the specified length.
/// </summary>
/// <param name="stringLength">The desired length of the random string.</param>
/// <returns>A random alphanumeric string.</returns>
/// <exception cref="ArgumentException">Thrown when stringLength is less than 1.</exception>
public static string GetRandomString(int stringLength) =>
RandomNumberGenerator.GetString("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", stringLength);
/// <summary>
/// Extracts the partition key from an existing ID, or returns null if extraction fails.
/// </summary>
/// <param name="id">The ID to extract the partition key from.</param>
/// <param name="stringLength">The length of the random entropy string in the ID.</param>
/// <param name="partitionKeyLength">The length of the partition key if generating a new one.</param>
/// <param name="delimiter">The delimiter character used in the ID.</param>
/// <returns>The partition key if successfully extracted; otherwise, null.</returns>
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..];
}
}
@@ -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
/// <summary>
/// Initializes a new instance of the <see cref="IdGenerator"/> class.
/// </summary>
@@ -29,9 +18,9 @@ internal sealed partial class IdGenerator
/// <param name="conversationId">The conversation ID.</param>
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;
}
/// <summary>
@@ -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);
}
/// <summary>
@@ -90,81 +79,4 @@ internal sealed partial class IdGenerator
/// </summary>
/// <returns>A reasoning ID.</returns>
public string GenerateReasoningId() => this.Generate("rs");
/// <summary>
/// Generates a new ID with a structured format that includes a partition key.
/// </summary>
/// <param name="prefix">The prefix to add to the ID, typically indicating the resource type.</param>
/// <param name="stringLength">The length of the random entropy string in the ID.</param>
/// <param name="partitionKeyLength">The length of the partition key if generating a new one.</param>
/// <param name="infix">Optional additional text to insert between the prefix and the entropy.</param>
/// <param name="watermark">Optional text to insert in the middle of the entropy string for traceability.</param>
/// <param name="delimiter">The delimiter character used to separate parts of the ID.</param>
/// <param name="partitionKey">An explicit partition key to use. When provided, this value will be used instead of generating a new one.</param>
/// <param name="partitionKeyHint">An existing ID to extract the partition key from. When provided, the same partition key will be used instead of generating a new one.</param>
/// <returns>A new ID with format "{prefix}{delimiter}{infix}{entropy}{delimiter}{partitionKey}".</returns>
/// <exception cref="ArgumentException">Thrown when the watermark contains non-alphanumeric characters.</exception>
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}";
}
/// <summary>
/// Generates a secure random alphanumeric string of the specified length.
/// </summary>
/// <param name="stringLength">The desired length of the random string.</param>
/// <returns>A random alphanumeric string.</returns>
/// <exception cref="ArgumentException">Thrown when stringLength is less than 1.</exception>
private static string GetRandomString(int stringLength) =>
RandomNumberGenerator.GetString("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", stringLength);
/// <summary>
/// Extracts the partition key from an existing ID, or returns null if extraction fails.
/// </summary>
/// <param name="id">The ID to extract the partition key from.</param>
/// <param name="stringLength">The length of the random entropy string in the ID.</param>
/// <param name="partitionKeyLength">The length of the partition key if generating a new one.</param>
/// <param name="delimiter">The delimiter character used in the ID.</param>
/// <returns>The partition key if successfully extracted; otherwise, null.</returns>
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..];
}
}
@@ -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;
/// <summary>
/// Extension methods for <see cref="IServiceCollection"/> to configure OpenAI Responses support.
/// Extension methods for <see cref="IServiceCollection"/> to configure OpenAI support.
/// </summary>
public static class MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions
{
/// <summary>
/// Adds support for exposing <see cref="AIAgent"/> instances via OpenAI ChatCompletions.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
public static IServiceCollection AddOpenAIChatCompletions(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.Configure<JsonOptions>(options => options.SerializerOptions.TypeInfoResolverChain.Add(ChatCompletionsJsonSerializerOptions.Default.TypeInfoResolver!));
return services;
}
/// <summary>
/// Adds support for exposing <see cref="AIAgent"/> instances via OpenAI Responses.
/// </summary>
@@ -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;
/// </summary>
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;
/// <summary>
/// Loads a JSON file from the conformance traces directory.
/// </summary>
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);
}
/// <summary>
/// Loads a JSON file from the conformance traces directory.
/// </summary>
protected static string LoadResponsesTraceFile(string relativePath)
=> LoadTraceFile(ResponsesTracesDirectory, relativePath);
/// <summary>
/// Loads a JSON document from the conformance traces directory.
/// </summary>
protected static JsonDocument LoadTraceDocument(string relativePath)
protected static JsonDocument LoadResponsesTraceDocument(string relativePath)
{
var json = LoadTraceFile(relativePath);
var json = LoadResponsesTraceFile(relativePath);
return JsonDocument.Parse(json);
}
/// <summary>
/// Loads a JSON file from the conformance traces directory.
/// </summary>
protected static string LoadChatCompletionsTraceFile(string relativePath)
=> LoadTraceFile(ChatCompletionsTracesDirectory, relativePath);
/// <summary>
/// Loads a JSON document from the conformance traces directory.
/// </summary>
protected static JsonDocument LoadChatCompletionsTraceDocument(string relativePath)
{
var json = LoadChatCompletionsTraceFile(relativePath);
return JsonDocument.Parse(json);
}
@@ -61,6 +86,20 @@ public abstract class ConformanceTestBase : IAsyncDisposable
}
}
/// <summary>
/// Asserts that a JSON element has any of the passed string values.
/// </summary>
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}'");
}
}
/// <summary>
/// Asserts that a JSON element has a specific string value.
/// </summary>
@@ -75,6 +114,20 @@ public abstract class ConformanceTestBase : IAsyncDisposable
}
}
/// <summary>
/// Asserts that a JSON element has a specific string value.
/// </summary>
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}'");
}
}
/// <summary>
/// Asserts that a JSON element has a specific integer value.
/// </summary>
@@ -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<AIAgent>(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<AIAgent>(agentName);
this._app.MapOpenAIResponses(agent);
this._app.MapOpenAIChatCompletions(agent);
await this._app.StartAsync();
@@ -188,12 +245,21 @@ public abstract class ConformanceTestBase : IAsyncDisposable
/// <summary>
/// Sends a POST request with JSON content to the test server.
/// </summary>
protected async Task<HttpResponseMessage> SendRequestAsync(HttpClient client, string agentName, string requestJson)
protected async Task<HttpResponseMessage> 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);
}
/// <summary>
/// Sends a POST request with JSON content to the test server.
/// </summary>
protected async Task<HttpResponseMessage> 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);
}
/// <summary>
/// Parses the response JSON and returns a JsonDocument.
/// </summary>
@@ -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
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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
}
}
}
}
@@ -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"
}
@@ -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
}
@@ -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"
}
@@ -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
}
@@ -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]
@@ -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
}
@@ -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"
}
@@ -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);
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>$(ProjectsCoreTargetFrameworks)</TargetFrameworks>
@@ -27,4 +27,16 @@
</None>
</ItemGroup>
<ItemGroup>
<Content Remove="ConformanceTraces\ChatCompletions\function_calling\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\function_calling\response.json" />
<Content Remove="ConformanceTraces\ChatCompletions\json_mode\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\json_mode\response.json" />
<Content Remove="ConformanceTraces\ChatCompletions\multi_turn\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\multi_turn\response.json" />
<Content Remove="ConformanceTraces\ChatCompletions\streaming\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\system_message\request.json" />
<Content Remove="ConformanceTraces\ChatCompletions\system_message\response.json" />
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<string, object?>() {
{ "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);
}
/// <summary>
/// Helper to parse chat completion chunks from SSE response.
/// </summary>
private static List<JsonElement> ParseChatCompletionChunksFromSse(string sseContent)
{
var chunks = new List<JsonElement>();
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;
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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();
}
}
/// <summary>
/// Verifies that streaming chat completions work correctly with the OpenAI SDK client.
/// </summary>
[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<ChatMessage> messages =
[
new UserChatMessage("Count to 3")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
// Assert
List<StreamingChatCompletionUpdate> 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);
}
/// <summary>
/// Verifies that non-streaming chat completions work correctly with the OpenAI SDK client.
/// </summary>
[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<ChatMessage> 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);
}
/// <summary>
/// Verifies that streaming chat completions can handle multiple content chunks.
/// </summary>
[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<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
// Assert
List<StreamingChatCompletionUpdate> 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<StreamingChatCompletionUpdate> contentUpdates = updates.Where(u => u.ContentUpdate.Count > 0).ToList();
Assert.True(contentUpdates.Count > 1, "Expected multiple content chunks in streaming response");
}
/// <summary>
/// Verifies that multiple agents can be accessed via the same server.
/// </summary>
[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<ChatMessage> 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);
}
/// <summary>
/// Verifies that streaming and non-streaming work correctly for the same agent.
/// </summary>
[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<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act - Non-streaming
ChatCompletion nonStreamingCompletion = await chatClient.CompleteChatAsync(messages);
// Act - Streaming
AsyncCollectionResult<StreamingChatCompletionUpdate> 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());
}
/// <summary>
/// Verifies that the finish reason is correctly set for completed responses.
/// </summary>
[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<ChatMessage> 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);
}
/// <summary>
/// Verifies that streaming responses contain the expected chunk sequence.
/// </summary>
[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<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
// Assert
List<StreamingChatCompletionUpdate> 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<StreamingChatCompletionUpdate> 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);
}
/// <summary>
/// Verifies that streaming responses properly handle empty responses.
/// </summary>
[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<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
// Assert
List<StreamingChatCompletionUpdate> 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);
}
/// <summary>
/// Verifies that non-streaming responses include proper metadata.
/// </summary>
[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<ChatMessage> 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);
}
/// <summary>
/// Verifies that streaming responses handle very long text correctly.
/// </summary>
[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<ChatMessage> messages =
[
new UserChatMessage("Generate long text")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> 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);
}
/// <summary>
/// Verifies that streaming responses properly handle single-word responses.
/// </summary>
[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<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> 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());
}
/// <summary>
/// Verifies that streaming responses preserve special characters and formatting.
/// </summary>
[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<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> 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());
}
/// <summary>
/// Verifies that non-streaming responses handle special characters correctly.
/// </summary>
[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<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
ChatCompletion completion = await chatClient.CompleteChatAsync(messages);
// Assert
string content = completion.Content[0].Text;
Assert.Equal(ExpectedResponse, content);
}
/// <summary>
/// Verifies that multiple sequential non-streaming requests work correctly.
/// </summary>
[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<ChatMessage> 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);
}
}
/// <summary>
/// Verifies that multiple sequential streaming requests work correctly.
/// </summary>
[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<ChatMessage> messages =
[
new UserChatMessage($"Request {i}")
];
AsyncCollectionResult<StreamingChatCompletionUpdate> 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());
}
}
/// <summary>
/// Verifies that completion IDs are unique across multiple requests.
/// </summary>
[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<string> completionIds = [];
for (int i = 0; i < 10; i++)
{
List<ChatMessage> 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
}
/// <summary>
/// Verifies that streaming responses all have the same ID within a single request.
/// </summary>
[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<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = chatClient.CompleteChatStreamingAsync(messages);
// Assert
List<string> 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]);
}
/// <summary>
/// Verifies that non-streaming responses work with system messages.
/// </summary>
[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<ChatMessage> 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);
}
/// <summary>
/// Verifies that responses handle newlines correctly.
/// </summary>
[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<ChatMessage> 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);
}
/// <summary>
/// Verifies that streaming responses handle newlines correctly.
/// </summary>
[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<ChatMessage> messages =
[
new UserChatMessage("Test")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> 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);
}
/// <summary>
/// Verifies that responses with conversation history work correctly.
/// </summary>
[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<ChatMessage> 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);
}
/// <summary>
/// Verifies that usage information is included in non-streaming responses.
/// </summary>
[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<ChatMessage> 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);
}
/// <summary>
/// Verifies that responses with function calls work correctly.
/// </summary>
[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<ChatMessage> 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);
}
/// <summary>
/// Verifies that streaming responses with function calls work correctly.
/// </summary>
[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<ChatMessage> messages =
[
new UserChatMessage("Calculate 2+2")
];
// Act
AsyncCollectionResult<StreamingChatCompletionUpdate> streamingResult = openAIChatClient.CompleteChatStreamingAsync(messages);
// Assert
List<StreamingChatCompletionUpdate> 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<HttpClient> 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<AIAgent>(agentName);
this._app.MapOpenAIChatCompletions(agent);
await this._app.StartAsync();
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
return testServer.CreateClient();
}
private async Task<HttpClient> 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<AIAgent>(agentName);
this._app.MapOpenAIChatCompletions(agent);
await this._app.StartAsync();
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
return testServer.CreateClient();
}
private async Task<HttpClient> 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<AIAgent>(name);
this._app.MapOpenAIChatCompletions(agent);
}
await this._app.StartAsync();
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
return testServer.CreateClient();
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<string>();
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<ChatCompletionChunk>();
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);
}
/// <summary>
/// Helper to parse chat completion chunks from SSE response.
/// </summary>
private static List<JsonElement> ParseChatCompletionChunksFromSse(string sseContent)
{
var chunks = new List<JsonElement>();
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
}
@@ -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);
@@ -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))
@@ -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);