.NET: Improve fidelity of OpenAI ChatCompletions Hosting (#1785)

* rename, support json serialization

* wip

* non-streaming

* streaming?

* proper streaming types

* comments + fix audio parse

* copilot suggestions

* proper stopsequences type

* build options as i could

* annotations

* proper generation of Id for chatcompletions

* string length as in chatcompletions api ref

* image url

* support tools

* rework API

* introduce tests for chatcompletions

* function calling / serialization tests / fixes

* more tests and coverage

* fix format

* sort usings

* nit

* address PR comments

* nits
This commit is contained in:
Korolev Dmitry
2025-11-05 10:55:26 +01:00
committed by GitHub
Unverified
parent 54db13c22f
commit bb8ef466de
46 changed files with 5687 additions and 406 deletions
@@ -1,70 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Buffers;
using System.ClientModel.Primitives;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Utils;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using OpenAI.Chat;
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
internal sealed class AIAgentChatCompletionsProcessor
internal static class AIAgentChatCompletionsProcessor
{
private readonly AIAgent _agent;
public AIAgentChatCompletionsProcessor(AIAgent agent)
public static async Task<IResult> CreateChatCompletionAsync(AIAgent agent, CreateChatCompletion request, CancellationToken cancellationToken)
{
this._agent = agent;
}
ArgumentNullException.ThrowIfNull(agent);
public async Task<IResult> CreateChatCompletionAsync(ChatCompletionOptions chatCompletionOptions, CancellationToken cancellationToken)
{
AgentThread? agentThread = null; // not supported to resolve from conversationId
var chatMessages = request.Messages.Select(i => i.ToChatMessage());
var chatClientAgentRunOptions = request.BuildOptions();
var inputItems = chatCompletionOptions.GetMessages();
var chatMessages = inputItems.AsChatMessages();
if (chatCompletionOptions.GetStream())
if (request.Stream == true)
{
return new OpenAIStreamingChatCompletionResult(this._agent, chatMessages);
return new StreamingResponse(agent, request, chatMessages, chatClientAgentRunOptions);
}
var agentResponse = await this._agent.RunAsync(chatMessages, agentThread, cancellationToken: cancellationToken).ConfigureAwait(false);
return new OpenAIChatCompletionResult(agentResponse);
var response = await agent.RunAsync(chatMessages, options: chatClientAgentRunOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
return Results.Ok(response.ToChatCompletion(request));
}
private sealed class OpenAIChatCompletionResult(AgentRunResponse agentRunResponse) : IResult
{
public async Task ExecuteAsync(HttpContext httpContext)
{
// note: OpenAI SDK types provide their own serialization implementation
// so we cant simply return IResult wrap for the typed-object.
// instead writing to the response body can be done.
var cancellationToken = httpContext.RequestAborted;
var response = httpContext.Response;
var chatResponse = agentRunResponse.AsChatResponse();
var openAIChatCompletion = chatResponse.AsOpenAIChatCompletion();
var openAIChatCompletionJsonModel = openAIChatCompletion as IJsonModel<ChatCompletion>;
Debug.Assert(openAIChatCompletionJsonModel is not null);
var writer = new Utf8JsonWriter(response.BodyWriter, new JsonWriterOptions { SkipValidation = false });
openAIChatCompletionJsonModel.Write(writer, ModelReaderWriterOptions.Json);
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
}
private sealed class OpenAIStreamingChatCompletionResult(AIAgent agent, IEnumerable<ChatMessage> chatMessages) : IResult
private sealed class StreamingResponse(
AIAgent agent,
CreateChatCompletion request,
IEnumerable<ChatMessage> chatMessages,
ChatClientAgentRunOptions? options) : IResult
{
public Task ExecuteAsync(HttpContext httpContext)
{
@@ -79,26 +53,99 @@ internal sealed class AIAgentChatCompletionsProcessor
httpContext.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
return SseFormatter.WriteAsync(
source: this.GetStreamingResponsesAsync(cancellationToken),
source: this.GetStreamingChunksAsync(cancellationToken),
destination: response.Body,
itemFormatter: (sseItem, bufferWriter) =>
{
var sseDataJsonModel = (IJsonModel<StreamingChatCompletionUpdate>)sseItem.Data;
var json = sseDataJsonModel.Write(ModelReaderWriterOptions.Json);
bufferWriter.Write(json);
using var writer = new Utf8JsonWriter(bufferWriter);
JsonSerializer.Serialize(writer, sseItem.Data, ChatCompletionsJsonContext.Default.ChatCompletionChunk);
writer.Flush();
},
cancellationToken);
}
private async IAsyncEnumerable<SseItem<StreamingChatCompletionUpdate>> GetStreamingResponsesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
private async IAsyncEnumerable<SseItem<ChatCompletionChunk>> GetStreamingChunksAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
AgentThread? agentThread = null;
// The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same timestamp.
DateTimeOffset? createdAt = null;
var chunkId = IdGeneratorHelpers.NewId(prefix: "chatcmpl", delimiter: "-", stringLength: 13);
var agentRunResponseUpdates = agent.RunStreamingAsync(chatMessages, thread: agentThread, cancellationToken: cancellationToken);
var chatResponseUpdates = agentRunResponseUpdates.AsChatResponseUpdatesAsync();
await foreach (var streamingChatCompletionUpdate in chatResponseUpdates.AsOpenAIStreamingChatCompletionUpdatesAsync(cancellationToken).ConfigureAwait(false))
await foreach (var agentRunResponseUpdate in agent.RunStreamingAsync(chatMessages, options: options, cancellationToken: cancellationToken).WithCancellation(cancellationToken))
{
yield return new SseItem<StreamingChatCompletionUpdate>(streamingChatCompletionUpdate);
var finishReason = (agentRunResponseUpdate.RawRepresentation is ChatResponseUpdate { FinishReason: not null } chatResponseUpdate)
? chatResponseUpdate.FinishReason.ToString()
: "stop";
var choiceChunks = new List<ChatCompletionChoiceChunk>();
CompletionUsage? usageDetails = null;
createdAt ??= agentRunResponseUpdate.CreatedAt;
foreach (var content in agentRunResponseUpdate.Contents)
{
// usage content is handled separately
if (content is UsageContent usageContent && usageContent.Details != null)
{
usageDetails = usageContent.Details.ToCompletionUsage();
continue;
}
ChatCompletionDelta? delta = content switch
{
TextContent textContent => new() { Content = textContent.Text },
// image
DataContent imageContent when imageContent.HasTopLevelMediaType("image") => new() { Content = imageContent.Base64Data.ToString() },
UriContent urlContent when urlContent.HasTopLevelMediaType("image") => new() { Content = urlContent.Uri.ToString() },
// audio
DataContent audioContent when audioContent.HasTopLevelMediaType("audio") => new() { Content = audioContent.Base64Data.ToString() },
// file
DataContent fileContent => new() { Content = fileContent.Base64Data.ToString() },
HostedFileContent fileContent => new() { Content = fileContent.FileId },
// function call
FunctionCallContent functionCallContent => new()
{
ToolCalls = [functionCallContent.ToChoiceMessageToolCall()]
},
// function result. ChatCompletions dont provide the results of function result per API reference
FunctionResultContent functionResultContent => null,
// ignore
_ => null
};
if (delta is null)
{
// unsupported but expected content type.
continue;
}
delta.Role = agentRunResponseUpdate.Role?.Value ?? "user";
var choiceChunk = new ChatCompletionChoiceChunk
{
Index = 0,
Delta = delta,
FinishReason = finishReason
};
choiceChunks.Add(choiceChunk);
}
var chunk = new ChatCompletionChunk
{
Id = chunkId,
Created = (createdAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(),
Model = request.Model,
Choices = choiceChunks,
Usage = usageDetails
};
yield return new(chunk);
}
}
}
@@ -0,0 +1,209 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
/// <summary>
/// Extension methods for converting agent responses to ChatCompletion models.
/// </summary>
internal static class AgentRunResponseExtensions
{
public static ChatCompletion ToChatCompletion(this AgentRunResponse agentRunResponse, CreateChatCompletion request)
{
IList<ChatCompletionChoice> choices = agentRunResponse.ToChoices();
return new ChatCompletion
{
Id = IdGeneratorHelpers.NewId(prefix: "chatcmpl", delimiter: "-", stringLength: 13),
Choices = choices,
Created = (agentRunResponse.CreatedAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(),
Model = request.Model,
Usage = agentRunResponse.Usage.ToCompletionUsage(),
ServiceTier = request.ServiceTier ?? "default"
};
}
public static List<ChatCompletionChoice> ToChoices(this AgentRunResponse agentRunResponse)
{
var chatCompletionChoices = new List<ChatCompletionChoice>();
var index = 0;
var finishReason = (agentRunResponse.RawRepresentation is ChatResponse { FinishReason: not null } chatResponse)
? chatResponse.FinishReason.ToString()
: "stop"; // "stop" is a natural stop point; returning this by-default
foreach (var message in agentRunResponse.Messages)
{
foreach (var content in message.Contents)
{
ChoiceMessage? choiceMessage = content switch
{
// text
TextContent textContent => new()
{
Content = textContent.Text
},
// image, see how MessageContentPartConverter packs the content types
DataContent imageContent when imageContent.HasTopLevelMediaType("image") => new()
{
Content = imageContent.Base64Data.ToString()
},
UriContent urlContent when urlContent.HasTopLevelMediaType("image") => new()
{
Content = urlContent.Uri.ToString()
},
// audio
DataContent audioContent when audioContent.HasTopLevelMediaType("audio") => new()
{
Audio = new()
{
Data = audioContent.Base64Data.ToString(),
Id = audioContent.Name,
//Transcript = ,
//ExpiresAt = ,
},
},
// file (neither audio nor image)
DataContent fileContent => new()
{
Content = fileContent.Base64Data.ToString()
},
HostedFileContent fileContent => new()
{
Content = fileContent.FileId
},
// function call
FunctionCallContent functionCallContent => new()
{
ToolCalls = [functionCallContent.ToChoiceMessageToolCall()]
},
// function result. ChatCompletions dont provide the results of function result per API reference
FunctionResultContent functionResultContent => null,
// ignore
_ => null
};
if (choiceMessage is null)
{
// not supported, but expected content type.
continue;
}
choiceMessage.Role = message.Role.Value;
choiceMessage.Annotations = content.Annotations?.ToChoiceMessageAnnotations();
var choice = new ChatCompletionChoice
{
Index = index++,
Message = choiceMessage,
FinishReason = finishReason
};
chatCompletionChoices.Add(choice);
}
}
return chatCompletionChoices;
}
/// <summary>
/// Converts UsageDetails to CompletionUsage.
/// </summary>
/// <param name="usage">The usage details to convert.</param>
/// <returns>A CompletionUsage object with zeros if usage is null.</returns>
public static CompletionUsage ToCompletionUsage(this UsageDetails? usage)
{
if (usage == null)
{
return CompletionUsage.Zero;
}
var cachedTokens = usage.AdditionalCounts?.TryGetValue("InputTokenDetails.CachedTokenCount", out var cachedInputToken) ?? false
? (int)cachedInputToken
: 0;
var reasoningTokens =
usage.AdditionalCounts?.TryGetValue("OutputTokenDetails.ReasoningTokenCount", out var reasoningToken) ?? false
? (int)reasoningToken
: 0;
return new CompletionUsage
{
PromptTokens = (int)(usage.InputTokenCount ?? 0),
PromptTokensDetails = new() { CachedTokens = cachedTokens },
CompletionTokens = (int)(usage.OutputTokenCount ?? 0),
CompletionTokensDetails = new() { ReasoningTokens = reasoningTokens },
TotalTokens = (int)(usage.TotalTokenCount ?? 0)
};
}
public static IList<ChoiceMessageAnnotation> ToChoiceMessageAnnotations(this IList<AIAnnotation> annotations)
{
var result = new List<ChoiceMessageAnnotation>();
foreach (var annotation in annotations.OfType<CitationAnnotation>())
{
if (annotation is null)
{
continue;
}
// may point to mulitple regions in the AIContent.
// we need to unroll another loop for regions then -> chatCompletions only point to single region per annotation
var regions = annotation.AnnotatedRegions?.OfType<TextSpanAnnotatedRegion>().Where(x => x.StartIndex is not null && x.EndIndex is not null);
if (regions is not null)
{
foreach (var region in regions)
{
result.Add(new()
{
AnnotationUrlCitation = new AnnotationUrlCitation
{
Url = annotation.Url?.ToString(),
Title = annotation.Title,
StartIndex = region.StartIndex,
EndIndex = region.EndIndex
}
});
}
}
else
{
result.Add(new()
{
AnnotationUrlCitation = new AnnotationUrlCitation
{
Url = annotation.Url?.ToString(),
Title = annotation.Title
}
});
}
}
return result;
}
public static ChoiceMessageToolCall ToChoiceMessageToolCall(this FunctionCallContent functionCall)
{
return new()
{
Id = functionCall.CallId,
Function = new()
{
Name = functionCall.Name,
Arguments = JsonSerializer.Serialize(functionCall.Arguments, ChatCompletionsJsonContext.Default.DictionaryStringObject)
}
};
}
}
@@ -0,0 +1,63 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
AllowOutOfOrderMetadataProperties = true,
WriteIndented = false)]
[JsonSerializable(typeof(Dictionary<string, string>))]
[JsonSerializable(typeof(CreateChatCompletion))]
[JsonSerializable(typeof(StopSequences))]
[JsonSerializable(typeof(ChatCompletion))]
[JsonSerializable(typeof(ChatCompletionRequestMessage))]
[JsonSerializable(typeof(IList<ChatCompletionRequestMessage>))]
[JsonSerializable(typeof(MessageContent))]
[JsonSerializable(typeof(MessageContentPart))]
[JsonSerializable(typeof(IReadOnlyList<MessageContentPart>))]
[JsonSerializable(typeof(TextContentPart))]
[JsonSerializable(typeof(ImageContentPart))]
[JsonSerializable(typeof(AudioContentPart))]
[JsonSerializable(typeof(FileContentPart))]
[JsonSerializable(typeof(ChatCompletionChoice))]
[JsonSerializable(typeof(IList<ChatCompletionChoice>))]
[JsonSerializable(typeof(ChoiceMessage))]
[JsonSerializable(typeof(ChoiceMessageAnnotation))]
[JsonSerializable(typeof(ChoiceMessageAudio))]
[JsonSerializable(typeof(ChoiceMessageFunctionCall))]
[JsonSerializable(typeof(ChoiceMessageToolCall))]
[JsonSerializable(typeof(AnnotationUrlCitation))]
[JsonSerializable(typeof(ChatCompletionChoiceChunk))]
[JsonSerializable(typeof(IList<ChatCompletionChoiceChunk>))]
[JsonSerializable(typeof(ChatCompletionChunk))]
[JsonSerializable(typeof(ChatCompletionDelta))]
[JsonSerializable(typeof(ToolChoice))]
[JsonSerializable(typeof(AllowedToolsChoice))]
[JsonSerializable(typeof(AllowedToolsConfiguration))]
[JsonSerializable(typeof(ToolDefinition))]
[JsonSerializable(typeof(IList<ToolDefinition>))]
[JsonSerializable(typeof(FunctionReference))]
[JsonSerializable(typeof(FunctionToolChoice))]
[JsonSerializable(typeof(CustomToolChoice))]
[JsonSerializable(typeof(CustomToolObject))]
[JsonSerializable(typeof(ResponseFormat))]
[JsonSerializable(typeof(TextResponseFormat))]
[JsonSerializable(typeof(JsonSchemaResponseFormat))]
[JsonSerializable(typeof(JsonSchemaConfiguration))]
[JsonSerializable(typeof(JsonObjectResponseFormat))]
[JsonSerializable(typeof(Tool))]
[JsonSerializable(typeof(IList<Tool>))]
[JsonSerializable(typeof(FunctionTool))]
[JsonSerializable(typeof(FunctionDefinition))]
[JsonSerializable(typeof(CustomTool))]
[JsonSerializable(typeof(CustomToolProperties))]
[JsonSerializable(typeof(CustomToolFormat))]
[ExcludeFromCodeCoverage]
internal sealed partial class ChatCompletionsJsonContext : JsonSerializerContext;
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
/// <summary>
/// Extension methods for JSON serialization.
/// </summary>
internal static class ChatCompletionsJsonSerializerOptions
{
/// <summary>
/// Gets the default JSON serializer options.
/// </summary>
public static JsonSerializerOptions Default { get; } = Create();
private static JsonSerializerOptions Create()
{
JsonSerializerOptions options = new(ChatCompletionsJsonContext.Default.Options);
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.MakeReadOnly();
return options;
}
}
@@ -0,0 +1,118 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
internal static class ChatClientAgentRunOptionsConverter
{
private static readonly JsonElement s_emptyJson = JsonDocument.Parse("{}").RootElement;
public static ChatClientAgentRunOptions BuildOptions(this CreateChatCompletion request)
{
ChatOptions chatOptions = new()
{
Temperature = request.Temperature,
MaxOutputTokens = request.MaxCompletionTokens,
FrequencyPenalty = request.FrequencyPenalty,
PresencePenalty = request.PresencePenalty,
Seed = request.Seed,
TopP = request.TopP,
StopSequences = request.Stop?.SequenceList ?? [],
ResponseFormat = request.ResponseFormat?.ToChatResponseFormat()
};
if (request.ToolChoice is not null)
{
chatOptions.ToolMode = request.ToolChoice.ToChatToolMode();
}
if (request.Tools?.Count > 0)
{
chatOptions.Tools = request.Tools.Select(x => x.ToAITool()).ToList();
}
return new()
{
ChatOptions = chatOptions
};
}
private static ChatResponseFormat ToChatResponseFormat(this ResponseFormat responseFormat)
{
if (responseFormat.IsText)
{
return ChatResponseFormat.Text;
}
if (responseFormat.IsJsonObject)
{
return ChatResponseFormat.Json;
}
if (responseFormat.IsJsonSchema)
{
var schema = responseFormat.JsonSchema.JsonSchema;
return ChatResponseFormat.ForJsonSchema(schema.Schema, schema.Name, schema.Description);
}
throw new ArgumentOutOfRangeException(nameof(responseFormat));
}
private static AITool ToAITool(this Tool tool)
{
if (tool is FunctionTool functionTool)
{
var function = functionTool.Function;
return AIFunctionFactory.CreateDeclaration(function.Name, function.Description, function.Parameters ?? s_emptyJson);
}
if (tool is CustomTool customTool)
{
var custom = customTool.Custom;
return new CustomAITool(custom.Name, custom.Description, custom.Format?.AdditionalProperties);
}
throw new ArgumentOutOfRangeException(nameof(tool));
}
private static ChatToolMode? ToChatToolMode(this ToolChoice toolChoice)
{
if (toolChoice.IsMode)
{
return toolChoice.Mode switch
{
"auto" => ChatToolMode.Auto,
"none" => ChatToolMode.None,
"required" => ChatToolMode.RequireAny,
_ => null
};
}
if (toolChoice.IsAllowedTools)
{
var mode = toolChoice.AllowedTools.AllowedTools.Mode;
return mode switch
{
"auto" => ChatToolMode.Auto,
"required" => ChatToolMode.RequireAny,
_ => null
};
}
if (toolChoice.IsFunctionTool)
{
var function = toolChoice.FunctionTool.Function;
return ChatToolMode.RequireSpecific(function.Name);
}
if (toolChoice.IsCustomTool)
{
var custom = toolChoice.CustomTool.Custom;
return ChatToolMode.RequireSpecific(custom.Name);
}
throw new ArgumentOutOfRangeException(nameof(toolChoice));
}
}
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
internal static class MessageContentPartConverter
{
public static AIContent? ToAIContent(MessageContentPart part)
{
return part switch
{
// text
TextContentPart textPart => new TextContent(textPart.Text),
// image
ImageContentPart imagePart when !string.IsNullOrEmpty(imagePart.UrlOrData) =>
imagePart.UrlOrData.StartsWith("data:", StringComparison.OrdinalIgnoreCase)
? new DataContent(imagePart.UrlOrData, "image/*")
: new UriContent(imagePart.Url, ImageUriToMediaType(imagePart.Url)),
// audio
AudioContentPart audioPart =>
new DataContent(audioPart.InputAudio.Data, audioPart.InputAudio.Format.ToUpperInvariant() switch
{
"MP3" => "audio/mpeg",
"WAV" => "audio/wav",
"OPUS" => "audio/opus",
"AAC" => "audio/aac",
"FLAC" => "audio/flac",
"PCM16" => "audio/pcm",
_ => "audio/*"
}),
// file
FileContentPart filePart when !string.IsNullOrEmpty(filePart.File.FileId)
=> new HostedFileContent(filePart.File.FileId),
FileContentPart filePart when !string.IsNullOrEmpty(filePart.File.FileData)
=> new DataContent(filePart.File.FileData, "application/octet-stream") { Name = filePart.File.Filename },
_ => null
};
}
private static string ImageUriToMediaType(Uri uri)
{
string absoluteUri = uri.AbsoluteUri;
return
absoluteUri.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? "image/png" :
absoluteUri.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" :
absoluteUri.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" :
absoluteUri.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ? "image/gif" :
absoluteUri.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase) ? "image/bmp" :
absoluteUri.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) ? "image/webp" :
"image/*";
}
}
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents a chat completion response returned by the model, based on the provided input.
/// </summary>
internal sealed record ChatCompletion
{
/// <summary>
/// A unique identifier for the chat completion.
/// </summary>
[JsonPropertyName("id")]
[JsonRequired]
public required string Id { get; init; }
/// <summary>
/// The object type, which is always "chat.completion".
/// </summary>
[JsonPropertyName("object")]
public string Object { get; init; } = "chat.completion";
/// <summary>
/// The Unix timestamp (in seconds) of when the chat completion was created.
/// </summary>
[JsonPropertyName("created")]
[JsonRequired]
public required long Created { get; init; }
/// <summary>
/// The model used for the chat completion.
/// </summary>
[JsonPropertyName("model")]
[JsonRequired]
public required string Model { get; init; }
/// <summary>
/// A list of chat completion choices. Can be more than one if n is greater than 1.
/// </summary>
[JsonPropertyName("choices")]
[JsonRequired]
public required IList<ChatCompletionChoice> Choices { get; init; }
/// <summary>
/// Usage statistics for the completion request.
/// </summary>
[JsonPropertyName("usage")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public CompletionUsage? Usage { get; init; }
/// <summary>
/// The service tier used for processing the request. This field is only included if the service_tier parameter is specified in the request.
/// </summary>
[JsonPropertyName("service_tier")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ServiceTier { get; init; }
/// <summary>
/// This fingerprint represents the backend configuration that the model runs with.
/// Can be used in conjunction with the seed request parameter to understand when backend changes have been made that might impact determinism.
/// </summary>
[JsonPropertyName("system_fingerprint")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SystemFingerprint { get; init; }
}
@@ -0,0 +1,216 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents a choice in a chat completion response.
/// </summary>
internal sealed record ChatCompletionChoice
{
/// <summary>
/// The index of the choice in the list of choices.
/// </summary>
[JsonPropertyName("index")]
public required int Index { get; init; }
/// <summary>
/// The reason the model stopped generating tokens.
/// This will be stop if the model hit a natural stop point or a provided stop sequence, length if the maximum number of tokens specified in the request was reached,
/// content_filter if content was omitted due to a flag from our content filters, tool_calls if the model called a tool,
/// or function_call (deprecated) if the model called a function.
/// </summary>
[JsonPropertyName("finish_reason")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? FinishReason { get; init; }
/// <summary>
/// A chat completion message generated by the model.
/// </summary>
[JsonPropertyName("message")]
public required ChoiceMessage Message { get; init; }
}
/// <summary>
/// A chat completion message generated by the model.
/// </summary>
internal sealed record ChoiceMessage
{
/// <summary>
/// The role of the author of this message.
/// </summary>
[JsonPropertyName("role")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Role { get; set; }
/// <summary>
/// A list of annotations for this message. Currently used for web search citations.
/// </summary>
[JsonPropertyName("annotations")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<ChoiceMessageAnnotation>? Annotations { get; set; }
/// <summary>
/// The contents of the message.
/// </summary>
[JsonPropertyName("content")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Content { get; set; }
/// <summary>
/// The refusal message generated by the model.
/// </summary>
[JsonPropertyName("refusal")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Refusal { get; set; }
/// <summary>
/// If the audio output modality is requested, this object contains data about the audio response from the model.
/// </summary>
[JsonPropertyName("audio")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ChoiceMessageAudio? Audio { get; set; }
/// <summary>
/// Deprecated and replaced by tool_calls. The name and arguments of a function that should be called, as generated by the model.
/// </summary>
[JsonPropertyName("function_call")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ChoiceMessageFunctionCall? FunctionCall { get; set; }
/// <summary>
/// The tool calls generated by the model, such as function calls.
/// </summary>
[JsonPropertyName("tool_calls")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<ChoiceMessageToolCall>? ToolCalls { get; set; }
}
/// <summary>
/// Audio output data in a chat completion message.
/// </summary>
internal sealed record ChoiceMessageAudio
{
/// <summary>
/// Base64 encoded audio bytes generated by the model, in the format specified in the request.
/// </summary>
[JsonPropertyName("data")]
public string? Data { get; init; }
/// <summary>
/// The Unix timestamp (in seconds) for when this audio response will no longer be accessible on the server for use in multi-turn conversations.
/// </summary>
[JsonPropertyName("expires_at")]
public int ExpiresAt { get; init; }
/// <summary>
/// Unique identifier for this audio response.
/// </summary>
[JsonPropertyName("id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Id { get; init; }
/// <summary>
/// Transcript of the audio generated by the model.
/// </summary>
[JsonPropertyName("transcript")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Transcript { get; init; }
}
/// <summary>
/// Deprecated. The name and arguments of a function that should be called, as generated by the model.
/// </summary>
internal sealed record ChoiceMessageFunctionCall
{
/// <summary>
/// The name of the function to call.
/// </summary>
[JsonPropertyName("name")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Name { get; init; }
/// <summary>
/// The arguments to call the function with, as generated by the model in JSON format.
/// Note that the model does not always generate valid JSON, and may hallucinate parameters not defined by your function schema.
/// Validate the arguments in your code before calling your function.
/// </summary>
[JsonPropertyName("arguments")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Arguments { get; init; }
}
/// <summary>
/// Represents a tool call generated by the model.
/// </summary>
internal sealed record ChoiceMessageToolCall
{
/// <summary>
/// The ID of the tool call.
/// </summary>
[JsonPropertyName("id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Id { get; init; }
/// <summary>
/// The type of the tool.
/// </summary>
public string Type => "function";
/// <summary>
/// The function that the model called.
/// </summary>
[JsonPropertyName("function")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ChoiceMessageFunctionCall? Function { get; set; }
}
/// <summary>
/// An annotation for a message, used for web search citations.
/// </summary>
internal sealed record ChoiceMessageAnnotation
{
/// <summary>
/// The type of annotation. Always 'url_citation' for web search results.
/// </summary>
[JsonPropertyName("type")]
public string Type => "url_citation";
/// <summary>
/// The URL citation details.
/// </summary>
[JsonPropertyName("url_citation")]
public required AnnotationUrlCitation AnnotationUrlCitation { get; init; }
}
/// <summary>
/// A citation to a URL for a web search result.
/// </summary>
internal sealed record AnnotationUrlCitation
{
/// <summary>
/// The character index in the message content where the citation ends.
/// </summary>
[JsonPropertyName("end_index")]
public int? EndIndex { get; init; }
/// <summary>
/// The character index in the message content where the citation starts.
/// </summary>
[JsonPropertyName("start_index")]
public int? StartIndex { get; init; }
/// <summary>
/// The title of the cited resource.
/// </summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>
/// The URL of the cited resource.
/// </summary>
[JsonPropertyName("url")]
public string? Url { get; set; }
}
@@ -0,0 +1,121 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents a chunk of chat completion response returned by the model, based on the provided input.
/// </summary>
internal sealed record ChatCompletionChunk
{
/// <summary>
/// A unique identifier for the chat completion. Each chunk has the same ID.
/// </summary>
[JsonPropertyName("id")]
[JsonRequired]
public required string Id { get; init; }
/// <summary>
/// A list of chat completion choices. Can be more than one if n is greater than 1.
/// </summary>
[JsonPropertyName("choices")]
[JsonRequired]
public required IList<ChatCompletionChoiceChunk> Choices { get; init; }
/// <summary>
/// The object type, which is always "chat.completion.chunk".
/// </summary>
[JsonPropertyName("object")]
public string Object => "chat.completion.chunk";
/// <summary>
/// The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same timestamp.
/// </summary>
[JsonPropertyName("created")]
[JsonRequired]
public required long Created { get; init; }
/// <summary>
/// The model to generate the completion.
/// </summary>
[JsonPropertyName("model")]
[JsonRequired]
public required string Model { get; init; }
/// <summary>
/// Usage statistics for the completion request.
/// </summary>
[JsonPropertyName("usage")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public CompletionUsage? Usage { get; init; }
/// <summary>
/// The service tier used for processing the request. This field is only included if the service_tier parameter is specified in the request.
/// </summary>
[JsonPropertyName("service_tier")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ServiceTier { get; init; }
/// <summary>
/// This fingerprint represents the backend configuration that the model runs with.
/// Can be used in conjunction with the seed request parameter to understand when backend changes have been made that might impact determinism.
/// </summary>
[JsonPropertyName("system_fingerprint")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SystemFingerprint { get; init; }
}
internal sealed record ChatCompletionChoiceChunk
{
/// <summary>
/// The index of the choice in the list of choices.
/// </summary>
[JsonPropertyName("index")]
public required int Index { get; init; }
/// <summary>
/// The reason the model stopped generating tokens.
/// This will be stop if the model hit a natural stop point or a provided stop sequence, length if the maximum number of tokens specified in the request was reached,
/// content_filter if content was omitted due to a flag from our content filters, tool_calls if the model called a tool, or function_call (deprecated) if the model called a function.
/// </summary>
[JsonPropertyName("finish_reason")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? FinishReason { get; init; }
[JsonPropertyName("delta")]
public required ChatCompletionDelta Delta { get; init; }
}
internal sealed record ChatCompletionDelta
{
/// <summary>
/// The contents of the chunk message.
/// </summary>
[JsonPropertyName("content")]
public string? Content { get; init; }
/// <summary>
/// The refusal message generated by the model.
/// </summary>
[JsonPropertyName("refusal")]
public string? Refusal { get; init; }
/// <summary>
/// The role of the author of this message.
/// </summary>
[JsonPropertyName("role")]
public string? Role { get; set; }
/// <summary>
/// Deprecated and replaced by tool_calls. The name and arguments of a function that should be called, as generated by the model.
/// </summary>
[JsonPropertyName("function_call")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ChoiceMessageFunctionCall? FunctionCall { get; set; }
[JsonPropertyName("tool_calls")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<ChoiceMessageToolCall>? ToolCalls { get; set; }
}
@@ -0,0 +1,175 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Converters;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents a message in a chat completion request.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "role", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)]
[JsonDerivedType(typeof(DeveloperMessage), "developer")]
[JsonDerivedType(typeof(SystemMessage), "system")]
[JsonDerivedType(typeof(UserMessage), "user")]
[JsonDerivedType(typeof(AssistantMessage), "assistant")]
[JsonDerivedType(typeof(ToolMessage), "tool")]
[JsonDerivedType(typeof(FunctionMessage), "function")]
internal abstract record ChatCompletionRequestMessage
{
/// <summary>
/// The role of the content.
/// </summary>
[JsonIgnore]
public abstract string Role { get; }
/// <summary>
/// The contents of the message.
/// </summary>
[JsonPropertyName("content")]
public required MessageContent Content { get; init; }
/// <summary>
/// Converts to a <see cref="ChatMessage"/>.
/// </summary>
/// <returns>A <see cref="ChatMessage"/> representing the message.</returns>
/// <exception cref="InvalidOperationException">Thrown when the content is neither text nor AI contents.</exception>
public virtual ChatMessage ToChatMessage()
{
if (this.Content.IsText)
{
return new(ChatRole.User, this.Content.Text);
}
else if (this.Content.IsContents)
{
var aiContents = this.Content.Contents.Select(MessageContentPartConverter.ToAIContent).Where(c => c is not null).ToList();
return new ChatMessage(ChatRole.User, aiContents!);
}
throw new InvalidOperationException("MessageContent has no value");
}
}
/// <summary>
/// A developer message in a chat completion request.
/// Developer messages are used to provide instructions to the model at the system level.
/// </summary>
internal sealed record DeveloperMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "developer";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// A system message in a chat completion request.
/// System messages provide high-level instructions for the conversation.
/// </summary>
internal sealed record SystemMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "system";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// A user message in a chat completion request.
/// User messages represent input from the end user.
/// </summary>
internal sealed record UserMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "user";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// An assistant message in a chat completion request.
/// Assistant messages represent previous responses from the model, used in multi-turn conversations.
/// </summary>
internal sealed record AssistantMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "assistant";
/// <summary>
/// An optional name for the participant.
/// Provides the model information to differentiate between participants of the same role.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// A tool message in a chat completion request.
/// Tool messages contain the result of a tool call made by the assistant.
/// </summary>
internal sealed record ToolMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "tool";
/// <summary>
/// Tool call that this message is responding to.
/// </summary>
[JsonPropertyName("tool_call_id")]
public required string ToolCallId { get; set; }
}
/// <summary>
/// Deprecated. A function message in a chat completion request.
/// Function messages have been replaced by tool messages.
/// </summary>
internal sealed record FunctionMessage : ChatCompletionRequestMessage
{
/// <inheritdoc/>
[JsonIgnore]
public override string Role => "function";
/// <summary>
/// The name of the function to call.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Converts to a <see cref="ChatMessage"/>.
/// </summary>
/// <returns>A <see cref="ChatMessage"/> representing the message.</returns>
/// <exception cref="InvalidOperationException">Thrown when the content is not text.</exception>
public override ChatMessage ToChatMessage()
{
if (this.Content.IsText)
{
return new(ChatRole.User, this.Content.Text);
}
throw new InvalidOperationException("FunctionMessage Content must be text");
}
}
@@ -0,0 +1,133 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents usage statistics for a chat completion request.
/// </summary>
internal sealed record CompletionUsage
{
public static CompletionUsage Zero { get; } = new()
{
CompletionTokens = 0,
PromptTokens = 0,
TotalTokens = 0,
CompletionTokensDetails = new()
{
AcceptedPredictionTokens = 0,
AudioTokens = 0,
ReasoningTokens = 0,
RejectedPredictionTokens = 0
},
PromptTokensDetails = new()
{
AudioTokens = 0,
CachedTokens = 0
},
};
/// <summary>
/// Number of tokens in the generated completion.
/// </summary>
[JsonPropertyName("completion_tokens")]
public int? CompletionTokens { get; set; }
/// <summary>
/// Number of tokens in the prompt.
/// </summary>
[JsonPropertyName("prompt_tokens")]
public int? PromptTokens { get; set; }
/// <summary>
/// Total number of tokens used in the request (prompt + completion).
/// </summary>
[JsonPropertyName("total_tokens")]
public int? TotalTokens { get; set; }
/// <summary>
/// Breakdown of tokens used in the generated completion.
/// </summary>
[JsonPropertyName("completion_tokens_details")]
public required CompletionTokensDetails CompletionTokensDetails { get; set; }
/// <summary>
/// Breakdown of tokens used in the prompt.
/// </summary>
[JsonPropertyName("prompt_tokens_details")]
public required PromptTokensDetails PromptTokensDetails { get; set; }
public static CompletionUsage operator +(CompletionUsage left, CompletionUsage right) => new()
{
CompletionTokens = left.CompletionTokens + right.CompletionTokens,
PromptTokens = left.PromptTokens + right.PromptTokens,
TotalTokens = left.TotalTokens + right.TotalTokens,
CompletionTokensDetails = left.CompletionTokensDetails + right.CompletionTokensDetails,
PromptTokensDetails = left.PromptTokensDetails + right.PromptTokensDetails
};
}
/// <summary>
/// Breakdown of tokens used in a completion.
/// </summary>
internal sealed record CompletionTokensDetails
{
/// <summary>
/// When using Predicted Outputs, the number of tokens in the prediction that appeared in the completion.
/// </summary>
[JsonPropertyName("accepted_prediction_tokens")]
public int AcceptedPredictionTokens { get; set; }
/// <summary>
/// Audio input tokens generated by the model.
/// </summary>
[JsonPropertyName("audio_tokens")]
public int AudioTokens { get; set; }
/// <summary>
/// Tokens generated by the model for reasoning.
/// </summary>
[JsonPropertyName("reasoning_tokens")]
public int ReasoningTokens { get; set; }
/// <summary>
/// When using Predicted Outputs, the number of tokens in the prediction that did not appear in the completion.
/// However, like reasoning tokens, these tokens are still counted in the total completion tokens for purposes of billing,
/// output, and context window limits.
/// </summary>
[JsonPropertyName("rejected_prediction_tokens")]
public int RejectedPredictionTokens { get; set; }
public static CompletionTokensDetails operator +(CompletionTokensDetails left, CompletionTokensDetails right) => new()
{
AcceptedPredictionTokens = left.AcceptedPredictionTokens + right.AcceptedPredictionTokens,
AudioTokens = left.AudioTokens + right.AudioTokens,
ReasoningTokens = left.ReasoningTokens + right.ReasoningTokens,
RejectedPredictionTokens = left.RejectedPredictionTokens + right.RejectedPredictionTokens
};
}
/// <summary>
/// Breakdown of tokens used in the prompt.
/// </summary>
internal sealed record PromptTokensDetails
{
/// <summary>
/// Audio input tokens present in the prompt.
/// </summary>
[JsonPropertyName("audio_tokens")]
public int AudioTokens { get; set; }
/// <summary>
/// Cached tokens present in the prompt.
/// </summary>
[JsonPropertyName("cached_tokens")]
public int CachedTokens { get; set; }
public static PromptTokensDetails operator +(PromptTokensDetails left, PromptTokensDetails right) => new()
{
AudioTokens = left.AudioTokens + right.AudioTokens,
CachedTokens = left.CachedTokens + right.CachedTokens
};
}
@@ -0,0 +1,258 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Request to create a chat completion.
/// </summary>
internal sealed record CreateChatCompletion
{
/// <summary>
/// A list of messages comprising the conversation so far.
/// </summary>
[JsonPropertyName("messages")]
[JsonRequired]
public required IList<ChatCompletionRequestMessage> Messages { get; set; }
/// <summary>
/// Model ID used to generate the response, like `gpt-4o` or `o3`.
/// </summary>
[JsonPropertyName("model")]
[JsonRequired]
public required string Model { get; set; }
/// <summary>
/// Parameters for audio output. Required when audio output is requested with modalities: ["audio"].
/// </summary>
[JsonPropertyName("audio")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? Audio { get; set; }
/// <summary>
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far.
/// </summary>
[JsonPropertyName("frequency_penalty")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? FrequencyPenalty { get; set; }
/// <summary>
/// Deprecated in favor of tool_choice. Controls which (if any) function is called by the model.
/// </summary>
[JsonPropertyName("function_call")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Obsolete("Deprecated in favor of ToolChoice.")]
public object? FunctionCall { get; set; }
/// <summary>
/// Deprecated in favor of tools. A list of functions the model may generate JSON inputs for.
/// </summary>
[JsonPropertyName("functions")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Obsolete("Deprecated in favor of Tools.")]
public IList<object>? Functions { get; set; }
/// <summary>
/// Modify the likelihood of specified tokens appearing in the completion.
/// </summary>
[JsonPropertyName("logit_bias")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, int>? LogitBias { get; set; }
/// <summary>
/// Whether to return log probabilities of the output tokens or not.
/// </summary>
[JsonPropertyName("logprobs")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Logprobs { get; set; }
/// <summary>
/// An upper bound for the number of tokens that can be generated for a completion, including visible output tokens and reasoning tokens.
/// </summary>
[JsonPropertyName("max_completion_tokens")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? MaxCompletionTokens { get; set; }
/// <summary>
/// The maximum number of tokens that can be generated in the chat completion. (Deprecated in favor of max_completion_tokens)
/// </summary>
[JsonPropertyName("max_tokens")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[Obsolete("Use MaxCompletionTokens instead. This property is deprecated and not compatible with o-series models.")]
public int? MaxTokens { get; set; }
/// <summary>
/// Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional
/// information about the object in a structured format, and querying for objects via API or the dashboard.
/// Keys are strings with a maximum length of 64 characters. Values are strings with a maximum length of 512 characters.
/// </summary>
[JsonPropertyName("metadata")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, string>? Metadata { get; set; }
/// <summary>
/// Types of content modalities the model can output. Can include "text" and/or "audio".
/// </summary>
[JsonPropertyName("modalities")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<string>? Modalities { get; set; }
/// <summary>
/// How many chat completion choices to generate for each input message.
/// </summary>
[JsonPropertyName("n")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? N { get; set; }
/// <summary>
/// Whether to enable parallel function calling during tool use.
/// </summary>
[JsonPropertyName("parallel_tool_calls")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? ParallelToolCalls { get; set; }
/// <summary>
/// Configuration for a Predicted Output, which can greatly improve response times when large parts of the model response are known ahead of time.
/// </summary>
[JsonPropertyName("prediction")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? Prediction { get; set; }
/// <summary>
/// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far.
/// </summary>
[JsonPropertyName("presence_penalty")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? PresencePenalty { get; set; }
/// <summary>
/// Used by OpenAI to cache responses for similar requests to optimize your cache hit rates.
/// </summary>
[JsonPropertyName("prompt_cache_key")]
public string? PromptCacheKey { get; init; }
/// <summary>
/// The reasoning effort level for o-series models. Can be "low", "medium", or "high".
/// </summary>
[JsonPropertyName("reasoning_effort")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ReasoningEffort { get; set; }
/// <summary>
/// An object specifying the format that the model must output.
/// </summary>
[JsonPropertyName("response_format")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ResponseFormat? ResponseFormat { get; set; }
/// <summary>
/// A stable identifier used to help detect users of your application that may be violating OpenAI's usage policies.
/// The IDs should be a string that uniquely identifies each user. We recommend hashing their username or email address,
/// in order to avoid sending us any identifying information.
/// </summary>
[JsonPropertyName("safety_identifier")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SafetyIdentifier { get; set; }
/// <summary>
/// If specified, the system will make a best effort to sample deterministically.
/// </summary>
[JsonPropertyName("seed")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? Seed { get; set; }
/// <summary>
/// Specifies the processing type used for serving the request.
/// If set to 'auto', the request will be processed with the service tier configured in the Project settings.
/// If set to 'default', the request will be processed with standard pricing and performance.
/// If set to 'flex' or 'priority', the request will be processed with the corresponding service tier.
/// Defaults to 'auto'.
/// </summary>
[JsonPropertyName("service_tier")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ServiceTier { get; set; }
/// <summary>
/// Up to 4 sequences where the API will stop generating further tokens.
/// </summary>
[JsonPropertyName("stop")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public StopSequences? Stop { get; set; }
/// <summary>
/// Whether or not to store the output of this chat completion request for use in model distillation or evals products.
/// </summary>
[JsonPropertyName("store")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Store { get; set; }
/// <summary>
/// If set to true, the model response data will be streamed to the client using server-sent events.
/// </summary>
[JsonPropertyName("stream")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Stream { get; set; }
/// <summary>
/// Options for streaming response. Only set this when you set stream: true.
/// </summary>
[JsonPropertyName("stream_options")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? StreamOptions { get; set; }
/// <summary>
/// What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random,
/// while lower values like 0.2 will make it more focused and deterministic.
/// We generally recommend altering this or top_p but not both. Defaults to 1.
/// </summary>
[JsonPropertyName("temperature")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? Temperature { get; set; }
/// <summary>
/// Controls which (if any) tool is called by the model.
/// </summary>
[JsonPropertyName("tool_choice")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ToolChoice? ToolChoice { get; set; }
/// <summary>
/// A list of tools the model may call. Can include custom tools or function tools.
/// </summary>
[JsonPropertyName("tools")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IList<Tool>? Tools { get; set; }
/// <summary>
/// An integer between 0 and 20 specifying the number of most likely tokens to return at each token position.
/// </summary>
[JsonPropertyName("top_logprobs")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? TopLogprobs { get; set; }
/// <summary>
/// An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of
/// the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.
/// We generally recommend altering this or temperature but not both.
/// </summary>
[JsonPropertyName("top_p")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public float? TopP { get; set; }
/// <summary>
/// Level of detail in the model's output. Can be "standard" or "verbose".
/// </summary>
[JsonPropertyName("verbosity")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Verbosity { get; set; } = "medium";
/// <summary>
/// Web search tool configuration for searching the web for relevant results.
/// </summary>
[JsonPropertyName("web_search_options")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? WebSearchOptions { get; set; }
}
@@ -0,0 +1,167 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Content which is a part of <see cref="ChatCompletionRequestMessage"/>.
/// Can be either a string, or a list of content parts
/// </summary>
[JsonConverter(typeof(MessageContentJsonConverter))]
internal sealed record MessageContent : IEquatable<MessageContent>
{
private MessageContent(string text)
{
this.Text = text ?? throw new ArgumentNullException(nameof(text));
this.Contents = null;
}
private MessageContent(IReadOnlyList<MessageContentPart> contents)
{
this.Contents = contents ?? throw new ArgumentNullException(nameof(contents));
this.Text = null;
}
/// <summary>
/// Creates an MessageContent from a text string.
/// </summary>
public static MessageContent FromText(string text) => new(text);
/// <summary>
/// Creates an MessageContent from a list of MessageContentPart items.
/// </summary>
public static MessageContent FromContents(IReadOnlyList<MessageContentPart> contents) => new(contents);
/// <summary>
/// Creates an MessageContent from a list of MessageContentPart items.
/// </summary>
public static MessageContent FromContents(params MessageContentPart[] contents) => new(contents);
/// <summary>
/// Implicit conversion from string to MessageContent.
/// </summary>
public static implicit operator MessageContent(string text) => FromText(text);
/// <summary>
/// Implicit conversion from List to MessageContent.
/// </summary>
public static implicit operator MessageContent(List<MessageContentPart> contents) => FromContents(contents);
/// <summary>
/// Gets whether this content is text.
/// </summary>
[MemberNotNullWhen(true, nameof(Text))]
public bool IsText => this.Text is not null;
/// <summary>
/// Gets whether this content is a list of ItemContent items.
/// </summary>
[MemberNotNullWhen(true, nameof(Contents))]
public bool IsContents => this.Contents is not null;
/// <summary>
/// Gets the text value, or null if this is not text content.
/// </summary>
public string? Text { get; }
/// <summary>
/// Gets the ItemContent items, or null if this is not a content list.
/// </summary>
public IReadOnlyList<MessageContentPart>? Contents { get; }
/// <inheritdoc/>
public bool Equals(MessageContent? other)
{
if (other is null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
// Both text
if (this.Text is not null && other.Text is not null)
{
return this.Text == other.Text;
}
// Both contents
if (this.Contents is not null
&& other.Contents is not null
&& this.Contents.Count == other.Contents.Count)
{
return this.Contents.SequenceEqual(other.Contents);
}
// One is text, one is contents - not equal
return false;
}
/// <inheritdoc/>
public override int GetHashCode()
{
if (this.Text is not null)
{
return this.Text.GetHashCode();
}
if (this.Contents is not null)
{
return this.Contents.Count > 0 ? this.Contents[0].GetHashCode() : 0;
}
return 0;
}
}
/// <summary>
/// JSON converter for <see cref="MessageContent"/>.
/// </summary>
internal sealed class MessageContentJsonConverter : JsonConverter<MessageContent>
{
public override MessageContent? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Check if it's a string
if (reader.TokenType == JsonTokenType.String)
{
var text = reader.GetString();
return text is not null ? MessageContent.FromText(text) : null;
}
// Check if it's an array of ItemContent
if (reader.TokenType == JsonTokenType.StartArray)
{
var contents = JsonSerializer.Deserialize(ref reader, ChatCompletionsJsonContext.Default.IReadOnlyListMessageContentPart);
return contents?.Count > 0
? MessageContent.FromContents(contents)
: MessageContent.FromText(string.Empty);
}
throw new JsonException($"Unexpected token type for MessageContent: {reader.TokenType}");
}
public override void Write(Utf8JsonWriter writer, MessageContent value, JsonSerializerOptions options)
{
if (value.IsText)
{
writer.WriteStringValue(value.Text);
}
else if (value.IsContents)
{
JsonSerializer.Serialize(writer, value.Contents, ChatCompletionsJsonContext.Default.IReadOnlyListMessageContentPart);
}
else
{
throw new JsonException("MessageContent has no value");
}
}
}
@@ -0,0 +1,160 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents a part of message content in a chat completion request.
/// Message content can be text, images, audio, or files.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)]
[JsonDerivedType(typeof(TextContentPart), "text")]
[JsonDerivedType(typeof(ImageContentPart), "image_url")]
[JsonDerivedType(typeof(AudioContentPart), "input_audio")]
[JsonDerivedType(typeof(FileContentPart), "file")]
internal abstract record MessageContentPart
{
/// <summary>
/// The type of the content.
/// </summary>
[JsonIgnore]
public abstract string Type { get; }
}
/// <summary>
/// A text content part in a message.
/// </summary>
internal sealed record TextContentPart : MessageContentPart
{
/// <inheritdoc />
[JsonIgnore]
public override string Type => "text";
/// <summary>
/// The text content.
/// </summary>
[JsonPropertyName("text")]
public required string Text { get; set; }
}
/// <summary>
/// An image content part in a message.
/// </summary>
internal sealed record ImageContentPart : MessageContentPart
{
/// <inheritdoc />
[JsonIgnore]
public override string Type => "image_url";
/// <summary>
/// Details about the image URL or base64-encoded image data.
/// </summary>
[JsonPropertyName("image_url")]
public required ImageUrl ImageUrl { get; set; }
/// <summary>
/// Gets the URL or base64-encoded data of the image.
/// </summary>
[JsonIgnore]
public string UrlOrData => this.ImageUrl.Url;
/// <summary>
/// Gets the URL of the image.
/// </summary>
[JsonIgnore]
public Uri Url => new(this.ImageUrl.Url);
}
/// <summary>
/// Details about an image for vision-enabled models.
/// </summary>
internal sealed record ImageUrl
{
/// <summary>
/// Either a URL of the image or the base64 encoded image data
/// </summary>
[JsonPropertyName("url")]
public required string Url { get; set; }
/// <summary>
/// Specifies the detail level of the image
/// </summary>
[JsonPropertyName("detail")]
public string? Detail { get; set; }
}
/// <summary>
/// An audio content part in a message.
/// </summary>
internal sealed record AudioContentPart : MessageContentPart
{
/// <inheritdoc />
[JsonIgnore]
public override string Type => "input_audio";
/// <summary>
/// The input audio data.
/// </summary>
[JsonPropertyName("input_audio")]
public required InputAudio InputAudio { get; set; }
}
/// <summary>
/// Input audio data for audio-enabled models.
/// </summary>
internal sealed record InputAudio
{
/// <summary>
/// Base64 encoded audio data.
/// </summary>
[JsonPropertyName("data")]
public required string Data { get; set; }
/// <summary>
/// The format of the encoded audio data. Currently supports "wav" and "mp3".
/// </summary>
[JsonPropertyName("format")]
public required string Format { get; set; }
}
/// <summary>
/// A file content part in a message.
/// </summary>
internal sealed record FileContentPart : MessageContentPart
{
/// <inheritdoc />
[JsonIgnore]
public override string Type => "file";
/// <summary>
/// The input file data.
/// </summary>
[JsonPropertyName("file")]
public required InputFile File { get; set; }
}
/// <summary>
/// Input file data for file-enabled models.
/// </summary>
internal sealed record InputFile
{
/// <summary>
/// The base64 encoded file data, used when passing the file to the model as a string.
/// </summary>
[JsonPropertyName("file_data")]
public string? FileData { get; set; }
/// <summary>
/// The ID of an uploaded file to use as input.
/// </summary>
[JsonPropertyName("file_id")]
public string? FileId { get; set; }
/// <summary>
/// The name of the file, used when passing the file to the model as a string.
/// </summary>
[JsonPropertyName("filename")]
public string? Filename { get; set; }
}
@@ -0,0 +1,282 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Specifies the format that the model must output.
/// </summary>
[JsonConverter(typeof(ResponseFormatConverter))]
internal sealed record ResponseFormat : IEquatable<ResponseFormat>
{
private ResponseFormat(TextResponseFormat text)
{
this.Text = text ?? throw new ArgumentNullException(nameof(text));
this.JsonSchema = null;
this.JsonObject = null;
}
private ResponseFormat(JsonSchemaResponseFormat jsonSchema)
{
this.JsonSchema = jsonSchema ?? throw new ArgumentNullException(nameof(jsonSchema));
this.Text = null;
this.JsonObject = null;
}
private ResponseFormat(JsonObjectResponseFormat jsonObject)
{
this.JsonObject = jsonObject ?? throw new ArgumentNullException(nameof(jsonObject));
this.Text = null;
this.JsonSchema = null;
}
/// <summary>
/// Creates a ResponseFormat for text output (default).
/// </summary>
public static ResponseFormat FromText() => new(new TextResponseFormat());
/// <summary>
/// Creates a ResponseFormat for JSON Schema output with Structured Outputs.
/// </summary>
public static ResponseFormat FromJsonSchema(JsonSchemaResponseFormat jsonSchema) => new(jsonSchema);
/// <summary>
/// Creates a ResponseFormat for JSON object output (older JSON mode).
/// </summary>
public static ResponseFormat FromJsonObject() => new(new JsonObjectResponseFormat());
/// <summary>
/// Gets whether this is a text response format.
/// </summary>
[MemberNotNullWhen(true, nameof(Text))]
public bool IsText => this.Text is not null;
/// <summary>
/// Gets whether this is a JSON schema response format.
/// </summary>
[MemberNotNullWhen(true, nameof(JsonSchema))]
public bool IsJsonSchema => this.JsonSchema is not null;
/// <summary>
/// Gets whether this is a JSON object response format.
/// </summary>
[MemberNotNullWhen(true, nameof(JsonObject))]
public bool IsJsonObject => this.JsonObject is not null;
/// <summary>
/// Gets the text response format, or null if this is not a text format.
/// </summary>
public TextResponseFormat? Text { get; }
/// <summary>
/// Gets the JSON schema response format, or null if this is not a JSON schema format.
/// </summary>
public JsonSchemaResponseFormat? JsonSchema { get; }
/// <summary>
/// Gets the JSON object response format, or null if this is not a JSON object format.
/// </summary>
public JsonObjectResponseFormat? JsonObject { get; }
/// <inheritdoc/>
public bool Equals(ResponseFormat? other)
{
if (other is null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
if (this.Text is not null && other.Text is not null)
{
return this.Text.Equals(other.Text);
}
if (this.JsonSchema is not null && other.JsonSchema is not null)
{
return this.JsonSchema.Equals(other.JsonSchema);
}
if (this.JsonObject is not null && other.JsonObject is not null)
{
return this.JsonObject.Equals(other.JsonObject);
}
return false;
}
/// <inheritdoc/>
public override int GetHashCode()
{
if (this.Text is not null)
{
return this.Text.GetHashCode();
}
if (this.JsonSchema is not null)
{
return this.JsonSchema.GetHashCode();
}
if (this.JsonObject is not null)
{
return this.JsonObject.GetHashCode();
}
return 0;
}
}
/// <summary>
/// Text response format. Default response format used to generate text responses.
/// </summary>
internal sealed record TextResponseFormat
{
/// <summary>
/// The type of response format. Always "text".
/// </summary>
[JsonPropertyName("type")]
public string Type => "text";
}
/// <summary>
/// JSON Schema response format. Used to generate structured JSON responses with Structured Outputs.
/// </summary>
internal sealed record JsonSchemaResponseFormat
{
/// <summary>
/// The type of response format. Always "json_schema".
/// </summary>
[JsonPropertyName("type")]
public string Type => "json_schema";
/// <summary>
/// Structured Outputs configuration options, including a JSON Schema.
/// </summary>
[JsonPropertyName("json_schema")]
[JsonRequired]
public required JsonSchemaConfiguration JsonSchema { get; init; }
}
/// <summary>
/// Configuration for JSON Schema Structured Outputs.
/// </summary>
internal sealed record JsonSchemaConfiguration
{
/// <summary>
/// The name of the schema.
/// </summary>
[JsonPropertyName("name")]
[JsonRequired]
public required string Name { get; init; }
/// <summary>
/// A description of the schema.
/// </summary>
[JsonPropertyName("description")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; init; }
/// <summary>
/// The JSON Schema definition.
/// </summary>
[JsonPropertyName("schema")]
[JsonRequired]
public required JsonElement Schema { get; init; }
/// <summary>
/// Whether to enable strict schema adherence.
/// </summary>
[JsonPropertyName("strict")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Strict { get; init; }
}
/// <summary>
/// JSON object response format. An older method of generating JSON responses.
/// Using json_schema is recommended for models that support it.
/// </summary>
internal sealed record JsonObjectResponseFormat
{
/// <summary>
/// The type of response format. Always "json_object".
/// </summary>
[JsonPropertyName("type")]
public string Type => "json_object";
}
/// <summary>
/// JSON converter for <see cref="ResponseFormat"/> that handles different response format types.
/// </summary>
internal sealed class ResponseFormatConverter : JsonConverter<ResponseFormat>
{
/// <inheritdoc/>
public override ResponseFormat? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}
if (reader.TokenType == JsonTokenType.StartObject)
{
using var doc = JsonDocument.ParseValue(ref reader);
var root = doc.RootElement;
if (root.TryGetProperty("type", out var typeProperty))
{
var type = typeProperty.GetString();
return type switch
{
"text" => ResponseFormat.FromText(),
"json_schema" => ResponseFormat.FromJsonSchema(
JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.JsonSchemaResponseFormat)!),
"json_object" => ResponseFormat.FromJsonObject(),
_ => throw new JsonException($"Unknown response format type: {type}")
};
}
throw new JsonException("Response format object must have a 'type' property.");
}
throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing ResponseFormat.");
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, ResponseFormat? value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}
if (value.IsText)
{
JsonSerializer.Serialize(writer, value.Text, ChatCompletionsJsonContext.Default.TextResponseFormat);
}
else if (value.IsJsonSchema)
{
JsonSerializer.Serialize(writer, value.JsonSchema, ChatCompletionsJsonContext.Default.JsonSchemaResponseFormat);
}
else if (value.IsJsonObject)
{
JsonSerializer.Serialize(writer, value.JsonObject, ChatCompletionsJsonContext.Default.JsonObjectResponseFormat);
}
else
{
writer.WriteNullValue();
}
}
}
@@ -0,0 +1,193 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents stop sequences for chat completion generation.
/// Up to 4 sequences where the API will stop generating further tokens.
/// </summary>
[JsonConverter(typeof(StopSequencesConverter))]
internal sealed record StopSequences : IEquatable<StopSequences>
{
private StopSequences(string singleSequence)
{
this.SingleSequence = singleSequence ?? throw new ArgumentNullException(nameof(singleSequence));
this.Sequences = null;
}
private StopSequences(IList<string> sequences)
{
if (sequences is null || sequences.Count == 0)
{
throw new ArgumentException("Sequences cannot be null or empty.", nameof(sequences));
}
if (sequences.Count > 4)
{
throw new ArgumentException("Maximum of 4 stop sequences are allowed.", nameof(sequences));
}
this.Sequences = sequences;
this.SingleSequence = null;
}
/// <summary>
/// Creates a StopSequences from a single stop sequence string.
/// </summary>
public static StopSequences FromString(string sequence) => new(sequence);
/// <summary>
/// Creates a StopSequences from a list of stop sequences.
/// </summary>
public static StopSequences FromSequences(IList<string> sequences) => new(sequences);
/// <summary>
/// Implicit conversion from string to StopSequences.
/// </summary>
public static implicit operator StopSequences(string sequence) => FromString(sequence);
/// <summary>
/// Implicit conversion from string array to StopSequences.
/// </summary>
public static implicit operator StopSequences(string[] sequences) => FromSequences(sequences);
/// <summary>
/// Implicit conversion from List to StopSequences.
/// </summary>
public static implicit operator StopSequences(List<string> sequences) => FromSequences(sequences);
/// <summary>
/// Gets whether this is a single stop sequence.
/// </summary>
[MemberNotNullWhen(true, nameof(SingleSequence))]
public bool IsSingleSequence => this.SingleSequence is not null;
/// <summary>
/// Gets whether this contains multiple stop sequences.
/// </summary>
[MemberNotNullWhen(true, nameof(Sequences))]
public bool IsSequences => this.Sequences is not null;
/// <summary>
/// Gets the single stop sequence, or null if this contains multiple sequences.
/// </summary>
public string? SingleSequence { get; }
/// <summary>
/// Gets the list of stop sequences, or null if this is a single sequence.
/// </summary>
public IList<string>? Sequences { get; }
public IList<string> SequenceList =>
this.IsSingleSequence ? [this.SingleSequence] :
this.IsSequences ? this.Sequences : [];
/// <inheritdoc/>
public bool Equals(StopSequences? other)
{
if (other is null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
// Both single sequences
if (this.SingleSequence is not null && other.SingleSequence is not null)
{
return this.SingleSequence == other.SingleSequence;
}
// Both sequences
if (this.Sequences is not null && other.Sequences is not null)
{
return this.Sequences.SequenceEqual(other.Sequences);
}
// One is single, one is sequences - not equal
return false;
}
/// <inheritdoc/>
public override int GetHashCode()
{
if (this.SingleSequence is not null)
{
return this.SingleSequence.GetHashCode();
}
if (this.Sequences is not null)
{
return this.Sequences.Count > 0 ? this.Sequences[0].GetHashCode() : 0;
}
return 0;
}
}
/// <summary>
/// JSON converter for <see cref="StopSequences"/> that handles string, array, and null representations.
/// </summary>
internal sealed class StopSequencesConverter : JsonConverter<StopSequences>
{
/// <inheritdoc/>
public override StopSequences? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Handle null
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}
// Handle single string
if (reader.TokenType == JsonTokenType.String)
{
string? sequence = reader.GetString();
return sequence is not null ? StopSequences.FromString(sequence) : null;
}
// Handle array of strings
if (reader.TokenType == JsonTokenType.StartArray)
{
var sequences = JsonSerializer.Deserialize(ref reader, ChatCompletionsJsonContext.Default.IListString);
return sequences?.Count > 0
? StopSequences.FromSequences(sequences)
: StopSequences.FromString(string.Empty);
}
throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing StopSequences. Expected String, StartArray, or Null.");
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, StopSequences? value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}
if (value.IsSingleSequence)
{
writer.WriteStringValue(value.SingleSequence);
}
else if (value.IsSequences)
{
JsonSerializer.Serialize(writer, value.Sequences, ChatCompletionsJsonContext.Default.IReadOnlyListMessageContentPart);
}
else
{
writer.WriteNullValue();
}
}
}
@@ -0,0 +1,164 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Represents a tool that the model may call. Can be either a function tool or a custom tool.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(FunctionTool), "function")]
[JsonDerivedType(typeof(CustomTool), "custom")]
internal abstract record Tool
{
/// <summary>
/// The type of the tool.
/// </summary>
[JsonPropertyName("type")]
public abstract string Type { get; }
}
/// <summary>
/// A function tool that can be used to generate a response.
/// </summary>
internal sealed record FunctionTool : Tool
{
/// <summary>
/// The type of the tool. Always "function".
/// </summary>
[JsonPropertyName("type")]
public override string Type => "function";
/// <summary>
/// The function definition.
/// </summary>
[JsonPropertyName("function")]
[JsonRequired]
public required FunctionDefinition Function { get; init; }
}
/// <summary>
/// Definition of a function that can be called by the model.
/// </summary>
internal sealed record FunctionDefinition
{
/// <summary>
/// The name of the function to be called.
/// Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64.
/// </summary>
[JsonPropertyName("name")]
[JsonRequired]
public required string Name { get; init; }
/// <summary>
/// A description of what the function does, used by the model to choose when and how to call the function.
/// </summary>
[JsonPropertyName("description")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; init; }
/// <summary>
/// The parameters the function accepts, described as a JSON Schema object.
/// Omitting parameters defines a function with an empty parameter list.
/// </summary>
[JsonPropertyName("parameters")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonElement? Parameters { get; init; }
/// <summary>
/// Whether to enable strict schema adherence when generating the function call.
/// If set to true, the model will follow the exact schema defined in the parameters field.
/// Only a subset of JSON Schema is supported when strict is true.
/// Defaults to false.
/// </summary>
[JsonPropertyName("strict")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Strict { get; init; }
}
/// <summary>
/// A custom tool that processes input using a specified format.
/// </summary>
internal sealed record CustomTool : Tool
{
/// <summary>
/// The type of the tool. Always "custom".
/// </summary>
[JsonPropertyName("type")]
public override string Type => "custom";
/// <summary>
/// Properties of the custom tool.
/// </summary>
[JsonPropertyName("custom")]
[JsonRequired]
public required CustomToolProperties Custom { get; init; }
}
/// <summary>
/// A wrapper for MEAI <see cref="AITool"/>
/// </summary>
internal sealed class CustomAITool : AITool
{
public CustomAITool(string name, string? description, IReadOnlyDictionary<string, object?>? additionalProperties)
: base()
{
this.Name = name;
this.Description = description ?? string.Empty;
this.AdditionalProperties = additionalProperties ?? new Dictionary<string, object?>();
}
public override string Name { get; }
public override string Description { get; }
public override IReadOnlyDictionary<string, object?> AdditionalProperties { get; }
}
/// <summary>
/// Properties of a custom tool.
/// </summary>
internal sealed record CustomToolProperties
{
/// <summary>
/// The name of the custom tool, used to identify it in tool calls.
/// </summary>
[JsonPropertyName("name")]
[JsonRequired]
public required string Name { get; init; }
/// <summary>
/// Optional description of the custom tool, used to provide more context.
/// </summary>
[JsonPropertyName("description")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; init; }
/// <summary>
/// The input format for the custom tool. Default is unconstrained text.
/// </summary>
[JsonPropertyName("format")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public CustomToolFormat? Format { get; init; }
}
/// <summary>
/// The input format for a custom tool.
/// </summary>
internal sealed record CustomToolFormat
{
/// <summary>
/// The type of format. Can be various schema types.
/// </summary>
[JsonPropertyName("type")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Type { get; init; }
/// <summary>
/// Additional format properties (schema definition).
/// </summary>
[JsonExtensionData]
public Dictionary<string, object?>? AdditionalProperties { get; init; }
}
@@ -0,0 +1,384 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
/// <summary>
/// Controls which (if any) tool is called by the model.
/// </summary>
[JsonConverter(typeof(ToolChoiceConverter))]
internal sealed record ToolChoice : IEquatable<ToolChoice>
{
private ToolChoice(string mode)
{
this.Mode = mode ?? throw new ArgumentNullException(nameof(mode));
this.AllowedTools = null;
this.FunctionTool = null;
this.CustomTool = null;
}
private ToolChoice(AllowedToolsChoice allowedTools)
{
this.AllowedTools = allowedTools ?? throw new ArgumentNullException(nameof(allowedTools));
this.Mode = null;
this.FunctionTool = null;
this.CustomTool = null;
}
private ToolChoice(FunctionToolChoice functionTool)
{
this.FunctionTool = functionTool ?? throw new ArgumentNullException(nameof(functionTool));
this.Mode = null;
this.AllowedTools = null;
this.CustomTool = null;
}
private ToolChoice(CustomToolChoice customTool)
{
this.CustomTool = customTool ?? throw new ArgumentNullException(nameof(customTool));
this.Mode = null;
this.AllowedTools = null;
this.FunctionTool = null;
}
/// <summary>
/// Creates a ToolChoice from a mode string ("none", "auto", or "required").
/// </summary>
public static ToolChoice FromMode(string mode) => new(mode);
/// <summary>
/// Creates a ToolChoice that constrains tools to a pre-defined set.
/// </summary>
public static ToolChoice FromAllowedTools(AllowedToolsChoice allowedTools) => new(allowedTools);
/// <summary>
/// Creates a ToolChoice that forces the model to call a specific function.
/// </summary>
public static ToolChoice FromFunction(FunctionToolChoice functionTool) => new(functionTool);
/// <summary>
/// Creates a ToolChoice that forces the model to call a specific custom tool.
/// </summary>
public static ToolChoice FromCustom(CustomToolChoice customTool) => new(customTool);
/// <summary>
/// Implicit conversion from string to ToolChoice.
/// </summary>
public static implicit operator ToolChoice(string mode) => FromMode(mode);
/// <summary>
/// Gets whether this is a mode string.
/// </summary>
[MemberNotNullWhen(true, nameof(Mode))]
public bool IsMode => this.Mode is not null;
/// <summary>
/// Gets whether this is an allowed tools configuration.
/// </summary>
[MemberNotNullWhen(true, nameof(AllowedTools))]
public bool IsAllowedTools => this.AllowedTools is not null;
/// <summary>
/// Gets whether this is a function tool choice.
/// </summary>
[MemberNotNullWhen(true, nameof(FunctionTool))]
public bool IsFunctionTool => this.FunctionTool is not null;
/// <summary>
/// Gets whether this is a custom tool choice.
/// </summary>
[MemberNotNullWhen(true, nameof(CustomTool))]
public bool IsCustomTool => this.CustomTool is not null;
/// <summary>
/// Gets the mode string, or null if this is not a mode.
/// </summary>
public string? Mode { get; }
/// <summary>
/// Gets the allowed tools configuration, or null if this is not an allowed tools choice.
/// </summary>
public AllowedToolsChoice? AllowedTools { get; }
/// <summary>
/// Gets the function tool choice, or null if this is not a function tool choice.
/// </summary>
public FunctionToolChoice? FunctionTool { get; }
/// <summary>
/// Gets the custom tool choice, or null if this is not a custom tool choice.
/// </summary>
public CustomToolChoice? CustomTool { get; }
/// <inheritdoc/>
public bool Equals(ToolChoice? other)
{
if (other is null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
if (this.Mode is not null && other.Mode is not null)
{
return this.Mode == other.Mode;
}
if (this.AllowedTools is not null && other.AllowedTools is not null)
{
return this.AllowedTools.Equals(other.AllowedTools);
}
if (this.FunctionTool is not null && other.FunctionTool is not null)
{
return this.FunctionTool.Equals(other.FunctionTool);
}
if (this.CustomTool is not null && other.CustomTool is not null)
{
return this.CustomTool.Equals(other.CustomTool);
}
return false;
}
/// <inheritdoc/>
public override int GetHashCode()
{
if (this.Mode is not null)
{
return this.Mode.GetHashCode();
}
if (this.AllowedTools is not null)
{
return this.AllowedTools.GetHashCode();
}
if (this.FunctionTool is not null)
{
return this.FunctionTool.GetHashCode();
}
if (this.CustomTool is not null)
{
return this.CustomTool.GetHashCode();
}
return 0;
}
}
/// <summary>
/// Constrains the tools available to the model to a pre-defined set.
/// </summary>
internal sealed record AllowedToolsChoice
{
/// <summary>
/// The type of tool choice. Always "allowed_tools".
/// </summary>
[JsonPropertyName("type")]
public string Type => "allowed_tools";
/// <summary>
/// Constrains the tools available to the model to a pre-defined set.
/// </summary>
[JsonPropertyName("allowed_tools")]
[JsonRequired]
public required AllowedToolsConfiguration AllowedTools { get; init; }
}
/// <summary>
/// Configuration for allowed tools.
/// </summary>
internal sealed record AllowedToolsConfiguration
{
/// <summary>
/// Constrains the tools available to the model to a pre-defined set.
/// auto allows the model to pick from among the allowed tools and generate a message.
/// required requires the model to call one or more of the allowed tools.
/// </summary>
[JsonPropertyName("mode")]
[JsonRequired]
public required string Mode { get; init; }
/// <summary>
/// A list of tool definitions that the model should be allowed to call.
/// </summary>
[JsonPropertyName("tools")]
[JsonRequired]
public required IList<ToolDefinition> Tools { get; init; }
}
/// <summary>
/// A tool definition in the allowed tools list.
/// </summary>
internal sealed record ToolDefinition
{
/// <summary>
/// The type of tool (e.g., "function" or "custom").
/// </summary>
[JsonPropertyName("type")]
[JsonRequired]
public required string Type { get; init; }
/// <summary>
/// The function details if type is "function".
/// </summary>
[JsonPropertyName("function")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public FunctionReference? Function { get; init; }
}
/// <summary>
/// A reference to a function by name.
/// </summary>
internal sealed record FunctionReference
{
/// <summary>
/// The name of the function.
/// </summary>
[JsonPropertyName("name")]
[JsonRequired]
public required string Name { get; init; }
}
/// <summary>
/// Specifies a function tool the model should use.
/// </summary>
internal sealed record FunctionToolChoice
{
/// <summary>
/// The type of tool. Always "function".
/// </summary>
[JsonPropertyName("type")]
public string Type => "function";
/// <summary>
/// The function to call.
/// </summary>
[JsonPropertyName("function")]
[JsonRequired]
public required FunctionReference Function { get; init; }
}
/// <summary>
/// Specifies a custom tool the model should use.
/// </summary>
internal sealed record CustomToolChoice
{
/// <summary>
/// The type of tool. Always "custom".
/// </summary>
[JsonPropertyName("type")]
public string Type => "custom";
/// <summary>
/// The custom tool configuration.
/// </summary>
[JsonPropertyName("custom")]
[JsonRequired]
public required CustomToolObject Custom { get; init; }
}
/// <summary>
/// A reference to a custom tool object.
/// </summary>
internal sealed record CustomToolObject
{
/// <summary>
/// The name of the function.
/// </summary>
[JsonPropertyName("name")]
[JsonRequired]
public required string Name { get; init; }
}
/// <summary>
/// JSON converter for <see cref="ToolChoice"/> that handles string and object representations.
/// </summary>
internal sealed class ToolChoiceConverter : JsonConverter<ToolChoice>
{
/// <inheritdoc/>
public override ToolChoice? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}
if (reader.TokenType == JsonTokenType.String)
{
string? mode = reader.GetString();
return mode is not null ? ToolChoice.FromMode(mode) : null;
}
if (reader.TokenType == JsonTokenType.StartObject)
{
using var doc = JsonDocument.ParseValue(ref reader);
var root = doc.RootElement;
if (root.TryGetProperty("type", out var typeProperty))
{
var type = typeProperty.GetString();
return type switch
{
"allowed_tools" => ToolChoice.FromAllowedTools(
JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.AllowedToolsChoice)!),
"function" => ToolChoice.FromFunction(
JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.FunctionToolChoice)!),
"custom" => ToolChoice.FromCustom(
JsonSerializer.Deserialize(root.GetRawText(), ChatCompletionsJsonContext.Default.CustomToolChoice)!),
_ => throw new JsonException($"Unknown tool choice type: {type}")
};
}
throw new JsonException("Tool choice object must have a 'type' property.");
}
throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing ToolChoice.");
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, ToolChoice? value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}
if (value.IsMode)
{
writer.WriteStringValue(value.Mode);
}
else if (value.IsAllowedTools)
{
JsonSerializer.Serialize(writer, value.AllowedTools, ChatCompletionsJsonContext.Default.AllowedToolsChoice);
}
else if (value.IsFunctionTool)
{
JsonSerializer.Serialize(writer, value.FunctionTool, ChatCompletionsJsonContext.Default.FunctionToolChoice);
}
else if (value.IsCustomTool)
{
JsonSerializer.Serialize(writer, value.CustomTool, ChatCompletionsJsonContext.Default.CustomToolChoice);
}
else
{
writer.WriteNullValue();
}
}
}
@@ -1,52 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Reflection;
using Microsoft.Shared.Diagnostics;
using OpenAI.Chat;
namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Utils;
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1810:Initialize reference type static fields inline", Justification = "Specifically for accessing hidden members")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "Specifically for accessing hidden members")]
internal static class ChatCompletionsOptionsExtensions
{
private static readonly Func<ChatCompletionOptions, bool?> s_getStreamNullable;
private static readonly Func<ChatCompletionOptions, IList<ChatMessage>> s_getMessages;
static ChatCompletionsOptionsExtensions()
{
// OpenAI SDK does not have a simple way to get the input as a c# object.
// However, it does parse most of the interesting fields into internal properties of `ChatCompletionsOptions` object.
// --- Stream (internal bool? Stream { get; set; }) ---
const string StreamPropName = "Stream";
var streamProp = typeof(ChatCompletionOptions).GetProperty(StreamPropName, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new MissingMemberException(typeof(ChatCompletionOptions).FullName!, StreamPropName);
var streamGetter = streamProp.GetGetMethod(nonPublic: true) ?? throw new MissingMethodException($"{StreamPropName} getter not found.");
s_getStreamNullable = streamGetter.CreateDelegate<Func<ChatCompletionOptions, bool?>>();
// --- Messages (internal IList<OpenAI.Chat.ChatMessage> Messages { get; set; }) ---
const string InputPropName = "Messages";
var inputProp = typeof(ChatCompletionOptions).GetProperty(InputPropName, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new MissingMemberException(typeof(ChatCompletionOptions).FullName!, InputPropName);
var inputGetter = inputProp.GetGetMethod(nonPublic: true)
?? throw new MissingMethodException($"{InputPropName} getter not found.");
s_getMessages = inputGetter.CreateDelegate<Func<ChatCompletionOptions, IList<ChatMessage>>>();
}
public static IList<ChatMessage> GetMessages(this ChatCompletionOptions options)
{
Throw.IfNull(options);
return s_getMessages(options);
}
public static bool GetStream(this ChatCompletionOptions options)
{
Throw.IfNull(options);
return s_getStreamNullable(options) ?? false;
}
}
@@ -1,16 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
using Microsoft.AspNetCore.Http;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using OpenAI.Chat;
namespace Microsoft.AspNetCore.Builder;
@@ -20,47 +19,54 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt
/// Maps OpenAI ChatCompletions API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI ChatCompletions endpoints to.</param>
/// <param name="agentName">The name of the AI agent service registered in the dependency injection container. This name is used to resolve the <see cref="AIAgent"/> instance from the keyed services.</param>
/// <param name="agentBuilder">The builder for <see cref="AIAgent"/> to map the OpenAI ChatCompletions endpoints for.</param>
public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder)
=> MapOpenAIChatCompletions(endpoints, agentBuilder, path: null);
/// <summary>
/// Maps OpenAI ChatCompletions API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI ChatCompletions endpoints to.</param>
/// <param name="agentBuilder">The builder for <see cref="AIAgent"/> to map the OpenAI ChatCompletions endpoints for.</param>
/// <param name="path">Custom route path for the chat completions endpoint.</param>
public static void MapOpenAIChatCompletions(
public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string? path)
{
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentBuilder.Name);
return MapOpenAIChatCompletions(endpoints, agent, path);
}
/// <summary>
/// Maps OpenAI ChatCompletions API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI ChatCompletions endpoints to.</param>
/// <param name="agent">The <see cref="AIAgent"/> instance to map the OpenAI ChatCompletions endpoints for.</param>
public static IEndpointConventionBuilder MapOpenAIChatCompletions(this IEndpointRouteBuilder endpoints, AIAgent agent)
=> MapOpenAIChatCompletions(endpoints, agent, path: null);
/// <summary>
/// Maps OpenAI ChatCompletions API endpoints to the specified <see cref="IEndpointRouteBuilder"/> for the given <see cref="AIAgent"/>.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the OpenAI ChatCompletions endpoints to.</param>
/// <param name="agent">The <see cref="AIAgent"/> instance to map the OpenAI ChatCompletions endpoints for.</param>
/// <param name="path">Custom route path for the chat completions endpoint.</param>
public static IEndpointConventionBuilder MapOpenAIChatCompletions(
this IEndpointRouteBuilder endpoints,
string agentName,
[StringSyntax("Route")] string? path = null)
AIAgent agent,
[StringSyntax("Route")] string? path)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentName);
if (path is null)
{
ValidateAgentName(agentName);
}
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent.Name));
ValidateAgentName(agent.Name);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
path ??= $"/{agentName}/v1/chat/completions";
var chatCompletionsRouteGroup = endpoints.MapGroup(path);
MapChatCompletions(chatCompletionsRouteGroup, agent);
}
private static void MapChatCompletions(IEndpointRouteBuilder routeGroup, AIAgent agent)
{
path ??= $"/{agent.Name}/v1/chat/completions";
var group = endpoints.MapGroup(path);
var endpointAgentName = agent.DisplayName;
var chatCompletionsProcessor = new AIAgentChatCompletionsProcessor(agent);
routeGroup.MapPost("/", async (HttpContext requestContext, CancellationToken cancellationToken) =>
{
var requestBinary = await BinaryData.FromStreamAsync(requestContext.Request.Body, cancellationToken).ConfigureAwait(false);
group.MapPost("/", async ([FromBody] CreateChatCompletion request, CancellationToken cancellationToken)
=> await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, cancellationToken).ConfigureAwait(false))
.WithName(endpointAgentName + "/CreateChatCompletion");
var chatCompletionOptions = new ChatCompletionOptions();
var chatCompletionOptionsJsonModel = chatCompletionOptions as IJsonModel<ChatCompletionOptions>;
Debug.Assert(chatCompletionOptionsJsonModel is not null);
chatCompletionOptions = chatCompletionOptionsJsonModel.Create(requestBinary, ModelReaderWriterOptions.Json);
if (chatCompletionOptions is null)
{
return Results.BadRequest("Invalid request payload.");
}
return await chatCompletionsProcessor.CreateChatCompletionAsync(chatCompletionOptions, cancellationToken).ConfigureAwait(false);
}).WithName(endpointAgentName + "/CreateChatCompletion");
return group;
}
}
@@ -8,10 +8,24 @@ using Microsoft.Extensions.Hosting;
namespace Microsoft.Extensions.Hosting;
/// <summary>
/// Extension methods for <see cref="IHostApplicationBuilder"/> to configure OpenAI Responses support.
/// Extension methods for <see cref="IHostApplicationBuilder"/> to configure OpenAI support.
/// </summary>
public static class MicrosoftAgentAIHostingOpenAIHostApplicationBuilderExtensions
{
/// <summary>
/// Adds support for exposing <see cref="AIAgent"/> instances via OpenAI ChatCompletions.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
public static IHostApplicationBuilder AddOpenAIChatCompletions(this IHostApplicationBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddOpenAIChatCompletions();
return builder;
}
/// <summary>
/// Adds support for exposing <see cref="AIAgent"/> instances via OpenAI Responses.
/// </summary>
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
namespace Microsoft.Agents.AI.Hosting.OpenAI;
/// <summary>
/// Shared helpers to generate IDs.
/// </summary>
internal static partial class IdGeneratorHelpers
{
#if NET9_0_OR_GREATER
[GeneratedRegex("^[A-Za-z0-9]+$")]
private static partial Regex WatermarkRegex();
#else
private static readonly Regex s_watermarkRegex = new("^[A-Za-z0-9]+$", RegexOptions.Compiled);
private static Regex WatermarkRegex() => s_watermarkRegex;
#endif
/// <summary>
/// Generates a new ID with a structured format that includes a partition key.
/// </summary>
/// <param name="prefix">The prefix to add to the ID, typically indicating the resource type.</param>
/// <param name="stringLength">The length of the random entropy string in the ID.</param>
/// <param name="partitionKeyLength">The length of the partition key if generating a new one.</param>
/// <param name="infix">Optional additional text to insert between the prefix and the entropy.</param>
/// <param name="watermark">Optional text to insert in the middle of the entropy string for traceability.</param>
/// <param name="delimiter">The delimiter character used to separate parts of the ID.</param>
/// <param name="partitionKey">An explicit partition key to use. When provided, this value will be used instead of generating a new one.</param>
/// <param name="partitionKeyHint">An existing ID to extract the partition key from. When provided, the same partition key will be used instead of generating a new one.</param>
/// <returns>A new ID with format "{prefix}{delimiter}{infix}{entropy}{delimiter}{partitionKey}".</returns>
/// <exception cref="ArgumentException">Thrown when the watermark contains non-alphanumeric characters.</exception>
public static string NewId(string prefix, int stringLength = 32, int partitionKeyLength = 16, string infix = "",
string watermark = "", string delimiter = "_", string? partitionKey = null, string partitionKeyHint = "")
{
ArgumentOutOfRangeException.ThrowIfLessThan(stringLength, 1);
var entropy = GetRandomString(stringLength);
string pKey = partitionKey ?? GetPartitionIdOrDefault(partitionKeyHint) ?? GetRandomString(partitionKeyLength);
if (!string.IsNullOrEmpty(watermark))
{
if (!WatermarkRegex().IsMatch(watermark))
{
throw new ArgumentException($"Only alphanumeric characters may be in watermark: {watermark}",
nameof(watermark));
}
entropy = $"{entropy[..(stringLength / 2)]}{watermark}{entropy[(stringLength / 2)..]}";
}
infix ??= "";
prefix = !string.IsNullOrEmpty(prefix) ? $"{prefix}{delimiter}" : "";
return $"{prefix}{infix}{entropy}{pKey}";
}
/// <summary>
/// Generates a secure random alphanumeric string of the specified length.
/// </summary>
/// <param name="stringLength">The desired length of the random string.</param>
/// <returns>A random alphanumeric string.</returns>
/// <exception cref="ArgumentException">Thrown when stringLength is less than 1.</exception>
public static string GetRandomString(int stringLength) =>
RandomNumberGenerator.GetString("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", stringLength);
/// <summary>
/// Extracts the partition key from an existing ID, or returns null if extraction fails.
/// </summary>
/// <param name="id">The ID to extract the partition key from.</param>
/// <param name="stringLength">The length of the random entropy string in the ID.</param>
/// <param name="partitionKeyLength">The length of the partition key if generating a new one.</param>
/// <param name="delimiter">The delimiter character used in the ID.</param>
/// <returns>The partition key if successfully extracted; otherwise, null.</returns>
public static string? GetPartitionIdOrDefault(string? id, int stringLength = 32, int partitionKeyLength = 16,
string delimiter = "_")
{
if (string.IsNullOrEmpty(id))
{
return null;
}
var parts = id.Split([delimiter], StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
{
return null;
}
if (parts[1].Length < stringLength + partitionKeyLength)
{
return null;
}
// get last partitionKeyLength characters from the last part as the partition key
return parts[1][^partitionKeyLength..];
}
}
@@ -1,8 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models;
namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses;
@@ -14,14 +11,6 @@ internal sealed partial class IdGenerator
{
private readonly string _partitionId;
#if NET9_0_OR_GREATER
[GeneratedRegex("^[A-Za-z0-9]+$")]
private static partial Regex WatermarkRegex();
#else
private static readonly Regex s_watermarkRegex = new("^[A-Za-z0-9]+$", RegexOptions.Compiled);
private static Regex WatermarkRegex() => s_watermarkRegex;
#endif
/// <summary>
/// Initializes a new instance of the <see cref="IdGenerator"/> class.
/// </summary>
@@ -29,9 +18,9 @@ internal sealed partial class IdGenerator
/// <param name="conversationId">The conversation ID.</param>
public IdGenerator(string? responseId, string? conversationId)
{
this.ResponseId = responseId ?? NewId("resp");
this.ConversationId = conversationId ?? NewId("conv");
this._partitionId = GetPartitionIdOrDefault(this.ConversationId) ?? string.Empty;
this.ResponseId = responseId ?? IdGeneratorHelpers.NewId("resp");
this.ConversationId = conversationId ?? IdGeneratorHelpers.NewId("conv");
this._partitionId = IdGeneratorHelpers.GetPartitionIdOrDefault(this.ConversationId) ?? string.Empty;
}
/// <summary>
@@ -64,7 +53,7 @@ internal sealed partial class IdGenerator
public string Generate(string? category = null)
{
var prefix = string.IsNullOrEmpty(category) ? "id" : category;
return NewId(prefix, partitionKey: this._partitionId);
return IdGeneratorHelpers.NewId(prefix, partitionKey: this._partitionId);
}
/// <summary>
@@ -90,81 +79,4 @@ internal sealed partial class IdGenerator
/// </summary>
/// <returns>A reasoning ID.</returns>
public string GenerateReasoningId() => this.Generate("rs");
/// <summary>
/// Generates a new ID with a structured format that includes a partition key.
/// </summary>
/// <param name="prefix">The prefix to add to the ID, typically indicating the resource type.</param>
/// <param name="stringLength">The length of the random entropy string in the ID.</param>
/// <param name="partitionKeyLength">The length of the partition key if generating a new one.</param>
/// <param name="infix">Optional additional text to insert between the prefix and the entropy.</param>
/// <param name="watermark">Optional text to insert in the middle of the entropy string for traceability.</param>
/// <param name="delimiter">The delimiter character used to separate parts of the ID.</param>
/// <param name="partitionKey">An explicit partition key to use. When provided, this value will be used instead of generating a new one.</param>
/// <param name="partitionKeyHint">An existing ID to extract the partition key from. When provided, the same partition key will be used instead of generating a new one.</param>
/// <returns>A new ID with format "{prefix}{delimiter}{infix}{entropy}{delimiter}{partitionKey}".</returns>
/// <exception cref="ArgumentException">Thrown when the watermark contains non-alphanumeric characters.</exception>
private static string NewId(string prefix, int stringLength = 32, int partitionKeyLength = 16, string infix = "",
string watermark = "", string delimiter = "_", string? partitionKey = null, string partitionKeyHint = "")
{
ArgumentOutOfRangeException.ThrowIfLessThan(stringLength, 1);
var entropy = GetRandomString(stringLength);
string pKey = partitionKey ?? GetPartitionIdOrDefault(partitionKeyHint) ?? GetRandomString(partitionKeyLength);
if (!string.IsNullOrEmpty(watermark))
{
if (!WatermarkRegex().IsMatch(watermark))
{
throw new ArgumentException($"Only alphanumeric characters may be in watermark: {watermark}",
nameof(watermark));
}
entropy = $"{entropy[..(stringLength / 2)]}{watermark}{entropy[(stringLength / 2)..]}";
}
infix ??= "";
prefix = !string.IsNullOrEmpty(prefix) ? $"{prefix}{delimiter}" : "";
return $"{prefix}{infix}{entropy}{pKey}";
}
/// <summary>
/// Generates a secure random alphanumeric string of the specified length.
/// </summary>
/// <param name="stringLength">The desired length of the random string.</param>
/// <returns>A random alphanumeric string.</returns>
/// <exception cref="ArgumentException">Thrown when stringLength is less than 1.</exception>
private static string GetRandomString(int stringLength) =>
RandomNumberGenerator.GetString("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", stringLength);
/// <summary>
/// Extracts the partition key from an existing ID, or returns null if extraction fails.
/// </summary>
/// <param name="id">The ID to extract the partition key from.</param>
/// <param name="stringLength">The length of the random entropy string in the ID.</param>
/// <param name="partitionKeyLength">The length of the partition key if generating a new one.</param>
/// <param name="delimiter">The delimiter character used in the ID.</param>
/// <returns>The partition key if successfully extracted; otherwise, null.</returns>
private static string? GetPartitionIdOrDefault(string? id, int stringLength = 32, int partitionKeyLength = 16,
string delimiter = "_")
{
if (string.IsNullOrEmpty(id))
{
return null;
}
var parts = id.Split([delimiter], StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
{
return null;
}
if (parts[1].Length < stringLength + partitionKeyLength)
{
return null;
}
// get last partitionKeyLength characters from the last part as the partition key
return parts[1][^partitionKeyLength..];
}
}
@@ -2,16 +2,31 @@
using System;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions;
using Microsoft.Agents.AI.Hosting.OpenAI.Responses;
using Microsoft.AspNetCore.Http.Json;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Extension methods for <see cref="IServiceCollection"/> to configure OpenAI Responses support.
/// Extension methods for <see cref="IServiceCollection"/> to configure OpenAI support.
/// </summary>
public static class MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions
{
/// <summary>
/// Adds support for exposing <see cref="AIAgent"/> instances via OpenAI ChatCompletions.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
public static IServiceCollection AddOpenAIChatCompletions(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.Configure<JsonOptions>(options => options.SerializerOptions.TypeInfoResolverChain.Add(ChatCompletionsJsonSerializerOptions.Default.TypeInfoResolver!));
return services;
}
/// <summary>
/// Adds support for exposing <see cref="AIAgent"/> instances via OpenAI Responses.
/// </summary>