mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
@@ -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");
|
||||
|
||||
+102
-55
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+209
@@ -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)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+63
@@ -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;
|
||||
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
+118
@@ -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));
|
||||
}
|
||||
}
|
||||
+59
@@ -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/*";
|
||||
}
|
||||
}
|
||||
+68
@@ -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; }
|
||||
}
|
||||
+216
@@ -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; }
|
||||
}
|
||||
+121
@@ -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; }
|
||||
}
|
||||
+175
@@ -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");
|
||||
}
|
||||
}
|
||||
+133
@@ -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
|
||||
};
|
||||
}
|
||||
+258
@@ -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; }
|
||||
}
|
||||
+167
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
@@ -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; }
|
||||
}
|
||||
+282
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+193
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
-52
@@ -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;
|
||||
}
|
||||
}
|
||||
+44
-38
@@ -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>
|
||||
|
||||
+12
@@ -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
|
||||
}
|
||||
+33
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+43
@@ -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"
|
||||
}
|
||||
+36
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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"
|
||||
}
|
||||
+18
@@ -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
|
||||
}
|
||||
+33
@@ -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"
|
||||
}
|
||||
+12
@@ -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
|
||||
}
|
||||
+21
@@ -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]
|
||||
+14
@@ -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
|
||||
}
|
||||
+33
@@ -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"
|
||||
}
|
||||
+19
-19
@@ -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);
|
||||
|
||||
|
||||
+13
-1
@@ -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>
|
||||
|
||||
+495
@@ -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;
|
||||
}
|
||||
}
|
||||
+974
@@ -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();
|
||||
}
|
||||
}
|
||||
+576
@@ -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
|
||||
}
|
||||
+39
-39
@@ -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);
|
||||
|
||||
+47
-47
@@ -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))
|
||||
|
||||
+51
-51
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user