Merge branch 'main' into evgenyfedorov2/log_sampling

This commit is contained in:
evgenyfedorov2 2024-11-12 14:07:25 +01:00
Родитель e938600514 4b8dad5587
Коммит d49c1f1543
85 изменённых файлов: 2040 добавлений и 680 удалений

Просмотреть файл

@ -186,13 +186,13 @@
</Dependency>
</ProductDependencies>
<ToolsetDependencies>
<Dependency Name="Microsoft.DotNet.Arcade.Sdk" Version="9.0.0-beta.24501.3">
<Dependency Name="Microsoft.DotNet.Arcade.Sdk" Version="9.0.0-beta.24516.2">
<Uri>https://github.com/dotnet/arcade</Uri>
<Sha>e879259c14f58a55983b9a70dd3034cc650ee961</Sha>
<Sha>3c393bbd85ae16ddddba20d0b75035b0c6f1a52d</Sha>
</Dependency>
<Dependency Name="Microsoft.DotNet.Helix.Sdk" Version="9.0.0-beta.24501.3">
<Dependency Name="Microsoft.DotNet.Helix.Sdk" Version="9.0.0-beta.24516.2">
<Uri>https://github.com/dotnet/arcade</Uri>
<Sha>e879259c14f58a55983b9a70dd3034cc650ee961</Sha>
<Sha>3c393bbd85ae16ddddba20d0b75035b0c6f1a52d</Sha>
</Dependency>
</ToolsetDependencies>
</Dependencies>

Просмотреть файл

@ -38,7 +38,7 @@ internal static class RoslynExtensions
/// </list>
/// </summary>
/// <param name="compilation">The <see cref="Compilation"/> to consider for analysis.</param>
/// <param name="fullyQualifiedMetadataName">The fully-qualified metadata type name to find.</param>
/// <param name="fullyQualifiedMetadataName">The fully qualified metadata type name to find.</param>
/// <returns>The symbol to use for code analysis; otherwise, <see langword="null"/>.</returns>
// Copied from: https://github.com/dotnet/roslyn/blob/af7b0ebe2b0ed5c335a928626c25620566372dd1/src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Core/Extensions/CompilationExtensions.cs
public static INamedTypeSymbol? GetBestTypeByMetadataName(this Compilation compilation, string fullyQualifiedMetadataName)
@ -94,7 +94,7 @@ internal static class RoslynExtensions
/// <summary>
/// A thin wrapper over <see cref="GetBestTypeByMetadataName(Compilation, string)"/>,
/// but taking the type itself rather than the fully-qualified metadata type name.
/// but taking the type itself rather than the fully qualified metadata type name.
/// </summary>
/// <param name="compilation">The <see cref="Compilation"/> to consider for analysis.</param>
/// <param name="type">The type to find.</param>

Просмотреть файл

@ -3,7 +3,7 @@
namespace Microsoft.Extensions.AI;
/// <summary>Represents a tool that may be specified to an AI service.</summary>
/// <summary>Represents a tool that can be specified to an AI service.</summary>
public class AITool
{
/// <summary>Initializes a new instance of the <see cref="AITool"/> class.</summary>

Просмотреть файл

@ -1,5 +1,11 @@
# Release History
## 9.0.0-preview.9.24556.5
- Added a strongly-typed `ChatOptions.Seed` property.
- Improved `AdditionalPropertiesDictionary` with a `TryAdd` method, a strongly-typed `Enumerator`, and debugger-related attributes for improved debuggability.
- Fixed `AIJsonUtilities` schema generation for Boolean schemas.
## 9.0.0-preview.9.24525.1
- Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older.

Просмотреть файл

@ -11,6 +11,22 @@ namespace Microsoft.Extensions.AI;
/// <summary>Provides a collection of static methods for extending <see cref="IChatClient"/> instances.</summary>
public static class ChatClientExtensions
{
/// <summary>Asks the <see cref="IChatClient"/> for an object of type <typeparamref name="TService"/>.</summary>
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
/// <param name="client">The client.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="IChatClient"/>,
/// including itself or any services it might be wrapping.
/// </remarks>
public static TService? GetService<TService>(this IChatClient client, object? serviceKey = null)
{
_ = Throw.IfNull(client);
return (TService?)client.GetService(typeof(TService), serviceKey);
}
/// <summary>Sends a user chat text message to the model and returns the response messages.</summary>
/// <param name="client">The chat client.</param>
/// <param name="chatMessage">The text content for the chat message to send.</param>

Просмотреть файл

@ -11,7 +11,7 @@ public class ChatClientMetadata
/// <summary>Initializes a new instance of the <see cref="ChatClientMetadata"/> class.</summary>
/// <param name="providerName">The name of the chat completion provider, if applicable.</param>
/// <param name="providerUri">The URL for accessing the chat completion provider, if applicable.</param>
/// <param name="modelId">The id of the chat completion model used, if applicable.</param>
/// <param name="modelId">The ID of the chat completion model used, if applicable.</param>
public ChatClientMetadata(string? providerName = null, Uri? providerUri = null, string? modelId = null)
{
ModelId = modelId;
@ -25,7 +25,7 @@ public class ChatClientMetadata
/// <summary>Gets the URL for accessing the chat completion provider.</summary>
public Uri? ProviderUri { get; }
/// <summary>Gets the id of the model used by this chat completion provider.</summary>
/// <remarks>This may be null if either the name is unknown or there are multiple possible models associated with this instance.</remarks>
/// <summary>Gets the ID of the model used by this chat completion provider.</summary>
/// <remarks>This value can be null if either the name is unknown or there are multiple possible models associated with this instance.</remarks>
public string? ModelId { get; }
}

Просмотреть файл

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json.Serialization;
using Microsoft.Shared.Diagnostics;
@ -40,7 +41,7 @@ public class ChatCompletion
/// <summary>Gets the chat completion message.</summary>
/// <remarks>
/// If there are multiple choices, this property returns the first choice.
/// If <see cref="Choices"/> is empty, this will throw. Use <see cref="Choices"/> to access all choices directly."/>.
/// If <see cref="Choices"/> is empty, this property will throw. Use <see cref="Choices"/> to access all choices directly.
/// </remarks>
[JsonIgnore]
public ChatMessage Message
@ -85,6 +86,73 @@ public class ChatCompletion
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
/// <inheritdoc />
public override string ToString() =>
Choices is { Count: > 0 } choices ? string.Join(Environment.NewLine, choices) : string.Empty;
public override string ToString()
{
if (Choices.Count == 1)
{
return Choices[0].ToString();
}
StringBuilder sb = new();
for (int i = 0; i < Choices.Count; i++)
{
if (i > 0)
{
_ = sb.AppendLine().AppendLine();
}
_ = sb.Append("Choice ").Append(i).AppendLine(":").Append(Choices[i]);
}
return sb.ToString();
}
/// <summary>Creates an array of <see cref="StreamingChatCompletionUpdate" /> instances that represent this <see cref="ChatCompletion" />.</summary>
/// <returns>An array of <see cref="StreamingChatCompletionUpdate" /> instances that may be used to represent this <see cref="ChatCompletion" />.</returns>
public StreamingChatCompletionUpdate[] ToStreamingChatCompletionUpdates()
{
StreamingChatCompletionUpdate? extra = null;
if (AdditionalProperties is not null || Usage is not null)
{
extra = new StreamingChatCompletionUpdate
{
AdditionalProperties = AdditionalProperties
};
if (Usage is { } usage)
{
extra.Contents.Add(new UsageContent(usage));
}
}
int choicesCount = Choices.Count;
var updates = new StreamingChatCompletionUpdate[choicesCount + (extra is null ? 0 : 1)];
for (int choiceIndex = 0; choiceIndex < choicesCount; choiceIndex++)
{
ChatMessage choice = Choices[choiceIndex];
updates[choiceIndex] = new StreamingChatCompletionUpdate
{
ChoiceIndex = choiceIndex,
AdditionalProperties = choice.AdditionalProperties,
AuthorName = choice.AuthorName,
Contents = choice.Contents,
RawRepresentation = choice.RawRepresentation,
Role = choice.Role,
CompletionId = CompletionId,
CreatedAt = CreatedAt,
FinishReason = FinishReason,
ModelId = ModelId
};
}
if (extra is not null)
{
updates[choicesCount] = extra;
}
return updates;
}
}

Просмотреть файл

@ -42,9 +42,9 @@ public readonly struct ChatFinishReason : IEquatable<ChatFinishReason>
/// <summary>
/// Compares two instances.
/// </summary>
/// <param name="left">Left argument of the comparison.</param>
/// <param name="right">Right argument of the comparison.</param>
/// <returns><see langword="true" /> when equal, <see langword="false" /> otherwise.</returns>
/// <param name="left">The left argument of the comparison.</param>
/// <param name="right">The right argument of the comparison.</param>
/// <returns><see langword="true" /> if the two instances are equal; <see langword="false" /> if they aren't equal.</returns>
public static bool operator ==(ChatFinishReason left, ChatFinishReason right)
{
return left.Equals(right);
@ -53,9 +53,9 @@ public readonly struct ChatFinishReason : IEquatable<ChatFinishReason>
/// <summary>
/// Compares two instances.
/// </summary>
/// <param name="left">Left argument of the comparison.</param>
/// <param name="right">Right argument of the comparison.</param>
/// <returns><see langword="true" /> when not equal, <see langword="false" /> otherwise.</returns>
/// <param name="left">The left argument of the comparison.</param>
/// <param name="right">The right argument of the comparison.</param>
/// <returns><see langword="true" /> if the two instances aren't equal; <see langword="false" /> if they are equal.</returns>
public static bool operator !=(ChatFinishReason left, ChatFinishReason right)
{
return !(left == right);

Просмотреть файл

@ -55,7 +55,7 @@ public class ChatMessage
/// </summary>
/// <remarks>
/// If there is no <see cref="TextContent"/> instance in <see cref="Contents" />, then the getter returns <see langword="null" />,
/// and the setter will add a new <see cref="TextContent"/> instance with the provided value.
/// and the setter adds a new <see cref="TextContent"/> instance with the provided value.
/// </remarks>
[JsonIgnore]
public string? Text
@ -95,5 +95,6 @@ public class ChatMessage
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
/// <inheritdoc/>
public override string ToString() => Text ?? string.Empty;
public override string ToString() =>
string.Concat(Contents.OfType<TextContent>());
}

Просмотреть файл

@ -35,12 +35,12 @@ public class ChatOptions
/// </summary>
/// <remarks>
/// If null, no response format is specified and the client will use its default.
/// This may be set to <see cref="ChatResponseFormat.Text"/> to specify that the response should be unstructured text,
/// This property can be set to <see cref="ChatResponseFormat.Text"/> to specify that the response should be unstructured text,
/// to <see cref="ChatResponseFormat.Json"/> to specify that the response should be structured JSON data, or
/// an instance of <see cref="ChatResponseFormatJson"/> constructed with a specific JSON schema to request that the
/// response be structured JSON data according to that schema. It is up to the client implementation if or how
/// to honor the request. If the client implementation doesn't recognize the specific kind of <see cref="ChatResponseFormat"/>,
/// it may be ignored.
/// it can be ignored.
/// </remarks>
public ChatResponseFormat? ResponseFormat { get; set; }

Просмотреть файл

@ -29,7 +29,7 @@ public class ChatResponseFormat
/// <summary>Creates a <see cref="ChatResponseFormatJson"/> representing structured JSON data with the specified schema.</summary>
/// <param name="schema">The JSON schema.</param>
/// <param name="schemaName">An optional name of the schema, e.g. if the schema represents a particular class, this could be the name of the class.</param>
/// <param name="schemaName">An optional name of the schema. For example, if the schema represents a particular class, this could be the name of the class.</param>
/// <param name="schemaDescription">An optional description of the schema.</param>
/// <returns>The <see cref="ChatResponseFormatJson"/> instance.</returns>
public static ChatResponseFormatJson ForJsonSchema(

Просмотреть файл

@ -32,7 +32,7 @@ public readonly struct ChatRole : IEquatable<ChatRole>
/// Gets the value associated with this <see cref="ChatRole"/>.
/// </summary>
/// <remarks>
/// The value is what will be serialized into the "role" message field of the Chat Message format.
/// The value will be serialized into the "role" message field of the Chat Message format.
/// </remarks>
public string Value { get; }
@ -50,9 +50,9 @@ public readonly struct ChatRole : IEquatable<ChatRole>
/// Returns a value indicating whether two <see cref="ChatRole"/> instances are equivalent, as determined by a
/// case-insensitive comparison of their values.
/// </summary>
/// <param name="left"> the first <see cref="ChatRole"/> instance to compare.</param>
/// <param name="right"> the second <see cref="ChatRole"/> instance to compare.</param>
/// <returns> true if left and right are both null or have equivalent values; false otherwise. </returns>
/// <param name="left">The first <see cref="ChatRole"/> instance to compare.</param>
/// <param name="right">The second <see cref="ChatRole"/> instance to compare.</param>
/// <returns><see langword="true"/> if left and right are both null or have equivalent values; otherwise, <see langword="false"/>.</returns>
public static bool operator ==(ChatRole left, ChatRole right)
{
return left.Equals(right);
@ -62,9 +62,9 @@ public readonly struct ChatRole : IEquatable<ChatRole>
/// Returns a value indicating whether two <see cref="ChatRole"/> instances are not equivalent, as determined by a
/// case-insensitive comparison of their values.
/// </summary>
/// <param name="left"> the first <see cref="ChatRole"/> instance to compare. </param>
/// <param name="right"> the second <see cref="ChatRole"/> instance to compare. </param>
/// <returns> false if left and right are both null or have equivalent values; true otherwise. </returns>
/// <param name="left">The first <see cref="ChatRole"/> instance to compare. </param>
/// <param name="right">The second <see cref="ChatRole"/> instance to compare. </param>
/// <returns><see langword="true"/> if left and right have different values; <see langword="false"/> if they have equivalent values or are both null.</returns>
public static bool operator !=(ChatRole left, ChatRole right)
{
return !(left == right);

Просмотреть файл

@ -29,14 +29,14 @@ public class ChatToolMode
/// Gets a predefined <see cref="ChatToolMode"/> indicating that tool usage is optional.
/// </summary>
/// <remarks>
/// <see cref="ChatOptions.Tools"/> may contain zero or more <see cref="AITool"/>
/// <see cref="ChatOptions.Tools"/> can contain zero or more <see cref="AITool"/>
/// instances, and the <see cref="IChatClient"/> is free to invoke zero or more of them.
/// </remarks>
public static AutoChatToolMode Auto { get; } = new AutoChatToolMode();
/// <summary>
/// Gets a predefined <see cref="ChatToolMode"/> indicating that tool usage is required,
/// but that any tool may be selected. At least one tool must be provided in <see cref="ChatOptions.Tools"/>.
/// but that any tool can be selected. At least one tool must be provided in <see cref="ChatOptions.Tools"/>.
/// </summary>
public static RequiredChatToolMode RequireAny { get; } = new(requiredFunctionName: null);

Просмотреть файл

@ -38,7 +38,7 @@ public class DelegatingChatClient : IChatClient
protected IChatClient InnerClient { get; }
/// <summary>Provides a mechanism for releasing unmanaged resources.</summary>
/// <param name="disposing">true if being called from <see cref="Dispose()"/>; otherwise, false.</param>
/// <param name="disposing"><see langword="true"/> if being called from <see cref="Dispose()"/>; otherwise, <see langword="false"/>.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
@ -63,12 +63,13 @@ public class DelegatingChatClient : IChatClient
}
/// <inheritdoc />
public virtual TService? GetService<TService>(object? key = null)
where TService : class
public virtual object? GetService(Type serviceType, object? serviceKey = null)
{
#pragma warning disable S3060 // "is" should not be used with "this"
// If the key is non-null, we don't know what it means so pass through to the inner service
return key is null && this is TService service ? service : InnerClient.GetService<TService>(key);
#pragma warning restore S3060
_ = Throw.IfNull(serviceType);
// If the key is non-null, we don't know what it means so pass through to the inner service.
return
serviceKey is null && serviceType.IsInstanceOfType(this) ? this :
InnerClient.GetService(serviceType, serviceKey);
}
}

Просмотреть файл

@ -15,7 +15,7 @@ namespace Microsoft.Extensions.AI;
/// It is expected that all implementations of <see cref="IChatClient"/> support being used by multiple requests concurrently.
/// </para>
/// <para>
/// However, implementations of <see cref="IChatClient"/> may mutate the arguments supplied to <see cref="CompleteAsync"/> and
/// However, implementations of <see cref="IChatClient"/> might mutate the arguments supplied to <see cref="CompleteAsync"/> and
/// <see cref="CompleteStreamingAsync"/>, such as by adding additional messages to the messages list or configuring the options
/// instance. Thus, consumers of the interface either should avoid using shared instances of these arguments for concurrent
/// invocations or should otherwise ensure by construction that no <see cref="IChatClient"/> instances are used which might employ
@ -31,8 +31,8 @@ public interface IChatClient : IDisposable
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The response messages generated by the client.</returns>
/// <remarks>
/// The returned messages will not have been added to <paramref name="chatMessages"/>. However, any intermediate messages generated implicitly
/// by the client, including any messages for roundtrips to the model as part of the implementation of this request, will be included.
/// The returned messages aren't added to <paramref name="chatMessages"/>. However, any intermediate messages generated implicitly
/// by the client, including any messages for roundtrips to the model as part of the implementation of this request, are included.
/// </remarks>
Task<ChatCompletion> CompleteAsync(
IList<ChatMessage> chatMessages,
@ -45,8 +45,8 @@ public interface IChatClient : IDisposable
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The response messages generated by the client.</returns>
/// <remarks>
/// The returned messages will not have been added to <paramref name="chatMessages"/>. However, any intermediate messages generated implicitly
/// by the client, including any messages for roundtrips to the model as part of the implementation of this request, will be included.
/// The returned messages aren't added to <paramref name="chatMessages"/>. However, any intermediate messages generated implicitly
/// by the client, including any messages for roundtrips to the model as part of the implementation of this request, are included.
/// </remarks>
IAsyncEnumerable<StreamingChatCompletionUpdate> CompleteStreamingAsync(
IList<ChatMessage> chatMessages,
@ -56,14 +56,13 @@ public interface IChatClient : IDisposable
/// <summary>Gets metadata that describes the <see cref="IChatClient"/>.</summary>
ChatClientMetadata Metadata { get; }
/// <summary>Asks the <see cref="IChatClient"/> for an object of type <typeparamref name="TService"/>.</summary>
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
/// <param name="key">An optional key that may be used to help identify the target service.</param>
/// <summary>Asks the <see cref="IChatClient"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
/// <param name="serviceType">The type of object being requested.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly-typed services that may be provided by the <see cref="IChatClient"/>,
/// The purpose of this method is to allow for the retrieval of strongly typed services that might be provided by the <see cref="IChatClient"/>,
/// including itself or any services it might be wrapping.
/// </remarks>
TService? GetService<TService>(object? key = null)
where TService : class;
object? GetService(Type serviceType, object? serviceKey = null);
}

Просмотреть файл

@ -8,8 +8,8 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Indicates that a chat tool must be called. It may optionally nominate a specific function,
/// or if not, indicates that any of them may be selected.
/// Represents a mode where a chat tool must be called. This class can optionally nominate a specific function
/// or indicate that any of the functions can be selected.
/// </summary>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class RequiredChatToolMode : ChatToolMode
@ -18,7 +18,7 @@ public sealed class RequiredChatToolMode : ChatToolMode
/// Gets the name of a specific <see cref="AIFunction"/> that must be called.
/// </summary>
/// <remarks>
/// If the value is <see langword="null"/>, any available function may be selected (but at least one must be).
/// If the value is <see langword="null"/>, any available function can be selected (but at least one must be).
/// </remarks>
public string? RequiredFunctionName { get; }
@ -27,8 +27,8 @@ public sealed class RequiredChatToolMode : ChatToolMode
/// </summary>
/// <param name="requiredFunctionName">The name of the function that must be called.</param>
/// <remarks>
/// <paramref name="requiredFunctionName"/> may be <see langword="null"/>. However, it is preferable to use
/// <see cref="ChatToolMode.RequireAny"/> when any function may be selected.
/// <paramref name="requiredFunctionName"/> can be <see langword="null"/>. However, it's preferable to use
/// <see cref="ChatToolMode.RequireAny"/> when any function can be selected.
/// </remarks>
public RequiredChatToolMode(string? requiredFunctionName)
{

Просмотреть файл

@ -9,14 +9,35 @@ using System.Text.Json.Serialization;
namespace Microsoft.Extensions.AI;
// Conceptually this combines the roles of ChatCompletion and ChatMessage in streaming output.
// For ease of consumption, it also flattens the nested structure you see on streaming chunks in
// the OpenAI/Gemini APIs, so instead of a dictionary of choices, each update represents a single
// choice (and hence has its own role, choice ID, etc.).
/// <summary>
/// Represents a single response chunk from an <see cref="IChatClient"/>.
/// Represents a single streaming response chunk from an <see cref="IChatClient"/>.
/// </summary>
/// <remarks>
/// <para>
/// Conceptually, this combines the roles of <see cref="ChatCompletion"/> and <see cref="ChatMessage"/>
/// in streaming output. For ease of consumption, it also flattens the nested structure you see on
/// streaming chunks in some AI service, so instead of a dictionary of choices, each update represents a
/// single choice (and hence has its own role, choice ID, etc.).
/// </para>
/// <para>
/// <see cref="StreamingChatCompletionUpdate"/> is so named because it represents streaming updates
/// to a single chat completion. As such, it is considered erroneous for multiple updates that are part
/// of the same completion to contain competing values. For example, some updates that are part of
/// the same completion may have a <see langword="null"/> <see cref="StreamingChatCompletionUpdate.Role"/>
/// value, and others may have a non-<see langword="null"/> value, but all of those with a non-<see langword="null"/>
/// value must have the same value (e.g. <see cref="ChatRole.Assistant"/>. It should never be the case, for example,
/// that one <see cref="StreamingChatCompletionUpdate"/> in a completion has a role of <see cref="ChatRole.Assistant"/>
/// while another has a role of "AI".
/// </para>
/// <para>
/// The relationship between <see cref="ChatCompletion"/> and <see cref="StreamingChatCompletionUpdate"/> is
/// codified in the <see cref="StreamingChatCompletionUpdateExtensions.ToChatCompletionAsync"/> and
/// <see cref="ChatCompletion.ToStreamingChatCompletionUpdates"/>, which enable bidirectional conversions
/// between the two. Note, however, that the conversion may be slightly lossy, for example if multiple updates
/// all have different <see cref="StreamingChatCompletionUpdate.RawRepresentation"/> objects whereas there's
/// only one slot for such an object available in <see cref="ChatCompletion.RawRepresentation"/>.
/// </para>
/// </remarks>
public class StreamingChatCompletionUpdate
{
/// <summary>The completion update content items.</summary>
@ -95,5 +116,6 @@ public class StreamingChatCompletionUpdate
public string? ModelId { get; set; }
/// <inheritdoc/>
public override string ToString() => Text ?? string.Empty;
public override string ToString() =>
string.Concat(Contents.OfType<TextContent>());
}

Просмотреть файл

@ -0,0 +1,230 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Generic;
using System.Linq;
#if NET
using System.Runtime.InteropServices;
#endif
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
#pragma warning disable S109 // Magic numbers should not be used
#pragma warning disable S127 // "for" loop stop conditions should be invariant
namespace Microsoft.Extensions.AI;
/// <summary>
/// Provides extension methods for working with <see cref="StreamingChatCompletionUpdate"/> instances.
/// </summary>
public static class StreamingChatCompletionUpdateExtensions
{
/// <summary>Combines <see cref="StreamingChatCompletionUpdate"/> instances into a single <see cref="ChatCompletion"/>.</summary>
/// <param name="updates">The updates to be combined.</param>
/// <param name="coalesceContent">
/// <see langword="true"/> to attempt to coalesce contiguous <see cref="AIContent"/> items, where applicable,
/// into a single <see cref="AIContent"/>, in order to reduce the number of individual content items that are included in
/// the manufactured <see cref="ChatMessage"/> instances. When <see langword="false"/>, the original content items are used.
/// The default is <see langword="true"/>.
/// </param>
/// <returns>The combined <see cref="ChatCompletion"/>.</returns>
public static ChatCompletion ToChatCompletion(
this IEnumerable<StreamingChatCompletionUpdate> updates, bool coalesceContent = true)
{
_ = Throw.IfNull(updates);
ChatCompletion completion = new([]);
Dictionary<int, ChatMessage> messages = [];
foreach (var update in updates)
{
ProcessUpdate(update, messages, completion);
}
AddMessagesToCompletion(messages, completion, coalesceContent);
return completion;
}
/// <summary>Combines <see cref="StreamingChatCompletionUpdate"/> instances into a single <see cref="ChatCompletion"/>.</summary>
/// <param name="updates">The updates to be combined.</param>
/// <param name="coalesceContent">
/// <see langword="true"/> to attempt to coalesce contiguous <see cref="AIContent"/> items, where applicable,
/// into a single <see cref="AIContent"/>, in order to reduce the number of individual content items that are included in
/// the manufactured <see cref="ChatMessage"/> instances. When <see langword="false"/>, the original content items are used.
/// The default is <see langword="true"/>.
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The combined <see cref="ChatCompletion"/>.</returns>
public static Task<ChatCompletion> ToChatCompletionAsync(
this IAsyncEnumerable<StreamingChatCompletionUpdate> updates, bool coalesceContent = true, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(updates);
return ToChatCompletionAsync(updates, coalesceContent, cancellationToken);
static async Task<ChatCompletion> ToChatCompletionAsync(
IAsyncEnumerable<StreamingChatCompletionUpdate> updates, bool coalesceContent, CancellationToken cancellationToken)
{
ChatCompletion completion = new([]);
Dictionary<int, ChatMessage> messages = [];
await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
{
ProcessUpdate(update, messages, completion);
}
AddMessagesToCompletion(messages, completion, coalesceContent);
return completion;
}
}
/// <summary>Processes the <see cref="StreamingChatCompletionUpdate"/>, incorporating its contents into <paramref name="messages"/> and <paramref name="completion"/>.</summary>
/// <param name="update">The update to process.</param>
/// <param name="messages">The dictionary mapping <see cref="StreamingChatCompletionUpdate.ChoiceIndex"/> to the <see cref="ChatMessage"/> being built for that choice.</param>
/// <param name="completion">The <see cref="ChatCompletion"/> object whose properties should be updated based on <paramref name="update"/>.</param>
private static void ProcessUpdate(StreamingChatCompletionUpdate update, Dictionary<int, ChatMessage> messages, ChatCompletion completion)
{
completion.CompletionId ??= update.CompletionId;
completion.CreatedAt ??= update.CreatedAt;
completion.FinishReason ??= update.FinishReason;
completion.ModelId ??= update.ModelId;
#if NET
ChatMessage message = CollectionsMarshal.GetValueRefOrAddDefault(messages, update.ChoiceIndex, out _) ??=
new(default, new List<AIContent>());
#else
if (!messages.TryGetValue(update.ChoiceIndex, out ChatMessage? message))
{
messages[update.ChoiceIndex] = message = new(default, new List<AIContent>());
}
#endif
((List<AIContent>)message.Contents).AddRange(update.Contents);
message.AuthorName ??= update.AuthorName;
if (update.Role is ChatRole role && message.Role == default)
{
message.Role = role;
}
if (update.AdditionalProperties is not null)
{
if (message.AdditionalProperties is null)
{
message.AdditionalProperties = new(update.AdditionalProperties);
}
else
{
foreach (var entry in update.AdditionalProperties)
{
// Use first-wins behavior to match the behavior of the other properties.
_ = message.AdditionalProperties.TryAdd(entry.Key, entry.Value);
}
}
}
}
/// <summary>Finalizes the <paramref name="completion"/> object by transferring the <paramref name="messages"/> into it.</summary>
/// <param name="messages">The messages to process further and transfer into <paramref name="completion"/>.</param>
/// <param name="completion">The result <see cref="ChatCompletion"/> being built.</param>
/// <param name="coalesceContent">The corresponding option value provided to <see cref="ToChatCompletion"/> or <see cref="ToChatCompletionAsync"/>.</param>
private static void AddMessagesToCompletion(Dictionary<int, ChatMessage> messages, ChatCompletion completion, bool coalesceContent)
{
if (messages.Count <= 1)
{
foreach (var entry in messages)
{
AddMessage(completion, coalesceContent, entry);
}
}
else
{
foreach (var entry in messages.OrderBy(entry => entry.Key))
{
AddMessage(completion, coalesceContent, entry);
}
}
static void AddMessage(ChatCompletion completion, bool coalesceContent, KeyValuePair<int, ChatMessage> entry)
{
if (entry.Value.Role == default)
{
entry.Value.Role = ChatRole.Assistant;
}
if (coalesceContent)
{
CoalesceTextContent((List<AIContent>)entry.Value.Contents);
}
completion.Choices.Add(entry.Value);
if (completion.Usage is null)
{
foreach (var content in entry.Value.Contents)
{
if (content is UsageContent c)
{
completion.Usage = c.Details;
entry.Value.Contents = entry.Value.Contents.ToList();
_ = entry.Value.Contents.Remove(c);
break;
}
}
}
}
}
/// <summary>Coalesces sequential <see cref="TextContent"/> content elements.</summary>
private static void CoalesceTextContent(List<AIContent> contents)
{
StringBuilder? coalescedText = null;
// Iterate through all of the items in the list looking for contiguous items that can be coalesced.
int start = 0;
while (start < contents.Count - 1)
{
// We need at least two TextContents in a row to be able to coalesce.
if (contents[start] is not TextContent firstText)
{
start++;
continue;
}
if (contents[start + 1] is not TextContent secondText)
{
start += 2;
continue;
}
// Append the text from those nodes and continue appending subsequent TextContents until we run out.
// We null out nodes as their text is appended so that we can later remove them all in one O(N) operation.
coalescedText ??= new();
_ = coalescedText.Clear().Append(firstText.Text).Append(secondText.Text);
contents[start + 1] = null!;
int i = start + 2;
for (; i < contents.Count && contents[i] is TextContent next; i++)
{
_ = coalescedText.Append(next.Text);
contents[i] = null!;
}
// Store the replacement node.
contents[start] = new TextContent(coalescedText.ToString())
{
// We inherit the properties of the first text node. We don't currently propagate additional
// properties from the subsequent nodes. If we ever need to, we can add that here.
AdditionalProperties = firstText.AdditionalProperties?.Clone(),
};
start = i;
}
// Remove all of the null slots left over from the coalescing process.
_ = contents.RemoveAll(u => u is null);
}
}

Просмотреть файл

@ -15,7 +15,7 @@ public class AudioContent : DataContent
/// <summary>
/// Initializes a new instance of the <see cref="AudioContent"/> class.
/// </summary>
/// <param name="uri">The URI of the content. This may be a data URI.</param>
/// <param name="uri">The URI of the content. This can be a data URI.</param>
/// <param name="mediaType">The media type (also known as MIME type) represented by the content.</param>
public AudioContent(Uri uri, string? mediaType = null)
: base(uri, mediaType)
@ -25,7 +25,7 @@ public class AudioContent : DataContent
/// <summary>
/// Initializes a new instance of the <see cref="AudioContent"/> class.
/// </summary>
/// <param name="uri">The URI of the content. This may be a data URI.</param>
/// <param name="uri">The URI of the content. This can be a data URI.</param>
/// <param name="mediaType">The media type (also known as MIME type) represented by the content.</param>
[JsonConstructor]
public AudioContent([StringSyntax(StringSyntaxAttribute.Uri)] string uri, string? mediaType = null)

Просмотреть файл

@ -25,6 +25,7 @@ namespace Microsoft.Extensions.AI;
/// a <see cref="ReadOnlyMemory{T}"/>. In that case, a data URI will be constructed and returned.
/// </para>
/// </remarks>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public class DataContent : AIContent
{
// Design note:
@ -34,7 +35,7 @@ public class DataContent : AIContent
/// <summary>The string-based representation of the URI, including any data in the instance.</summary>
private string? _uri;
/// <summary>The data, lazily-initialized if the data is provided in a data URI.</summary>
/// <summary>The data, lazily initialized if the data is provided in a data URI.</summary>
private ReadOnlyMemory<byte>? _data;
/// <summary>Parsed data URI information.</summary>
@ -43,7 +44,7 @@ public class DataContent : AIContent
/// <summary>
/// Initializes a new instance of the <see cref="DataContent"/> class.
/// </summary>
/// <param name="uri">The URI of the content. This may be a data URI.</param>
/// <param name="uri">The URI of the content. This can be a data URI.</param>
/// <param name="mediaType">The media type (also known as MIME type) represented by the content.</param>
public DataContent(Uri uri, string? mediaType = null)
: this(Throw.IfNull(uri).ToString(), mediaType)
@ -53,7 +54,7 @@ public class DataContent : AIContent
/// <summary>
/// Initializes a new instance of the <see cref="DataContent"/> class.
/// </summary>
/// <param name="uri">The URI of the content. This may be a data URI.</param>
/// <param name="uri">The URI of the content. This can be a data URI.</param>
/// <param name="mediaType">The media type (also known as MIME type) represented by the content.</param>
[JsonConstructor]
public DataContent([StringSyntax(StringSyntaxAttribute.Uri)] string uri, string? mediaType = null)
@ -116,7 +117,7 @@ public class DataContent : AIContent
/// <summary>Gets the URI for this <see cref="DataContent"/>.</summary>
/// <remarks>
/// The returned URI is always a valid URI string, even if the instance was constructed from a <see cref="ReadOnlyMemory{Byte}"/>
/// or from a <see cref="System.Uri"/>. In the case of a <see cref="ReadOnlyMemory{T}"/>, this will return a data URI containing
/// or from a <see cref="System.Uri"/>. In the case of a <see cref="ReadOnlyMemory{T}"/>, this property returns a data URI containing
/// that data.
/// </remarks>
[StringSyntax(StringSyntaxAttribute.Uri)]
@ -155,10 +156,10 @@ public class DataContent : AIContent
/// <summary>Gets the media type (also known as MIME type) of the content.</summary>
/// <remarks>
/// If the media type was explicitly specified, this property will return that value.
/// If the media type was explicitly specified, this property returns that value.
/// If the media type was not explicitly specified, but a data URI was supplied and that data URI contained a non-default
/// media type, that media type will be returned.
/// Otherwise, this will return null.
/// media type, that media type is returned.
/// Otherwise, this property returns null.
/// </remarks>
[JsonPropertyOrder(1)]
public string? MediaType { get; private set; }
@ -167,17 +168,17 @@ public class DataContent : AIContent
/// Gets a value indicating whether the content contains data rather than only being a reference to data.
/// </summary>
/// <remarks>
/// If the instance is constructed from a <see cref="ReadOnlyMemory{Byte}"/> or from a data URI, this property will return <see langword="true"/>,
/// If the instance is constructed from a <see cref="ReadOnlyMemory{Byte}"/> or from a data URI, this property returns <see langword="true"/>,
/// as the instance actually contains all of the data it represents. If, however, the instance was constructed from another form of URI, one
/// that simply references where the data can be found but doesn't actually contain the data, this property will return <see langword="false"/>.
/// that simply references where the data can be found but doesn't actually contain the data, this property returns <see langword="false"/>.
/// </remarks>
[JsonIgnore]
public bool ContainsData => _dataUri is not null || _data is not null;
/// <summary>Gets the data represented by this instance.</summary>
/// <remarks>
/// If <see cref="ContainsData"/> is <see langword="true" />, this property will return the represented data.
/// If <see cref="ContainsData"/> is <see langword="false" />, this property will return <see langword="null" />.
/// If <see cref="ContainsData"/> is <see langword="true" />, this property returns the represented data.
/// If <see cref="ContainsData"/> is <see langword="false" />, this property returns <see langword="null" />.
/// </remarks>
[MemberNotNullWhen(true, nameof(ContainsData))]
[JsonIgnore]
@ -193,4 +194,16 @@ public class DataContent : AIContent
return _data;
}
}
/// <summary>Gets a string representing this instance to display in the debugger.</summary>
private string DebuggerDisplay
{
get
{
const int MaxLength = 80;
string uri = Uri;
return uri.Length <= MaxLength ? uri : $"{uri.Substring(0, MaxLength)}...";
}
}
}

Просмотреть файл

@ -21,8 +21,8 @@ public sealed class FunctionResultContent : AIContent
/// <param name="callId">The function call ID for which this is the result.</param>
/// <param name="name">The function name that produced the result.</param>
/// <param name="result">
/// This may be <see langword="null"/> if the function returned <see langword="null"/>, if the function was void-returning
/// and thus had no result, or if the function call failed. Typically, however, in order to provide meaningfully representative
/// <see langword="null"/> if the function returned <see langword="null"/> or was void-returning
/// and thus had no result, or if the function call failed. Typically, however, to provide meaningfully representative
/// information to an AI service, a human-readable representation of those conditions should be supplied.
/// </param>
[JsonConstructor]
@ -37,7 +37,7 @@ public sealed class FunctionResultContent : AIContent
/// Gets or sets the ID of the function call for which this is the result.
/// </summary>
/// <remarks>
/// If this is the result for a <see cref="FunctionCallContent"/>, this should contain the same
/// If this is the result for a <see cref="FunctionCallContent"/>, this property should contain the same
/// <see cref="FunctionCallContent.CallId"/> value.
/// </remarks>
public string CallId { get; set; }
@ -51,8 +51,8 @@ public sealed class FunctionResultContent : AIContent
/// Gets or sets the result of the function call, or a generic error message if the function call failed.
/// </summary>
/// <remarks>
/// This may be <see langword="null"/> if the function returned <see langword="null"/>, if the function was void-returning
/// and thus had no result, or if the function call failed. Typically, however, in order to provide meaningfully representative
/// <see langword="null"/> if the function returned <see langword="null"/> or was void-returning
/// and thus had no result, or if the function call failed. Typically, however, to provide meaningfully representative
/// information to an AI service, a human-readable representation of those conditions should be supplied.
/// </remarks>
public object? Result { get; set; }
@ -61,9 +61,9 @@ public sealed class FunctionResultContent : AIContent
/// Gets or sets an exception that occurred if the function call failed.
/// </summary>
/// <remarks>
/// This property is for information purposes only. The <see cref="Exception"/> is not serialized as part of serializing
/// instances of this class with <see cref="JsonSerializer"/>; as such, upon deserialization, this property will be <see langword="null"/>.
/// Consumers should not rely on <see langword="null"/> indicating success.
/// This property is for informational purposes only. The <see cref="Exception"/> is not serialized as part of serializing
/// instances of this class with <see cref="JsonSerializer"/>. As such, upon deserialization, this property will be <see langword="null"/>.
/// Consumers should not rely on <see langword="null"/> indicating success.
/// </remarks>
[JsonIgnore]
public Exception? Exception { get; set; }

Просмотреть файл

@ -15,7 +15,7 @@ public class ImageContent : DataContent
/// <summary>
/// Initializes a new instance of the <see cref="ImageContent"/> class.
/// </summary>
/// <param name="uri">The URI of the content. This may be a data URI.</param>
/// <param name="uri">The URI of the content. This can be a data URI.</param>
/// <param name="mediaType">The media type (also known as MIME type) represented by the content.</param>
public ImageContent(Uri uri, string? mediaType = null)
: base(uri, mediaType)
@ -25,7 +25,7 @@ public class ImageContent : DataContent
/// <summary>
/// Initializes a new instance of the <see cref="ImageContent"/> class.
/// </summary>
/// <param name="uri">The URI of the content. This may be a data URI.</param>
/// <param name="uri">The URI of the content. This can be a data URI.</param>
/// <param name="mediaType">The media type (also known as MIME type) represented by the content.</param>
[JsonConstructor]
public ImageContent([StringSyntax(StringSyntaxAttribute.Uri)] string uri, string? mediaType = null)

Просмотреть файл

@ -12,10 +12,10 @@ namespace Microsoft.Extensions.AI;
/// <summary>
/// Provides an optional base class for an <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/> that passes through calls to another instance.
/// </summary>
/// <typeparam name="TInput">Specifies the type of the input passed to the generator.</typeparam>
/// <typeparam name="TEmbedding">Specifies the type of the embedding instance produced by the generator.</typeparam>
/// <typeparam name="TInput">The type of the input passed to the generator.</typeparam>
/// <typeparam name="TEmbedding">The type of the embedding instance produced by the generator.</typeparam>
/// <remarks>
/// This is recommended as a base type when building generators that can be chained in any order around an underlying <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/>.
/// This type is recommended as a base type when building generators that can be chained in any order around an underlying <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/>.
/// The default implementation simply passes each call to the inner generator instance.
/// </remarks>
public class DelegatingEmbeddingGenerator<TInput, TEmbedding> : IEmbeddingGenerator<TInput, TEmbedding>
@ -41,7 +41,7 @@ public class DelegatingEmbeddingGenerator<TInput, TEmbedding> : IEmbeddingGenera
}
/// <summary>Provides a mechanism for releasing unmanaged resources.</summary>
/// <param name="disposing">true if being called from <see cref="Dispose()"/>; otherwise, false.</param>
/// <param name="disposing"><see langword="true"/> if being called from <see cref="Dispose()"/>; otherwise, <see langword="false"/>.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
@ -59,12 +59,13 @@ public class DelegatingEmbeddingGenerator<TInput, TEmbedding> : IEmbeddingGenera
InnerGenerator.GenerateAsync(values, options, cancellationToken);
/// <inheritdoc />
public virtual TService? GetService<TService>(object? key = null)
where TService : class
public virtual object? GetService(Type serviceType, object? serviceKey = null)
{
#pragma warning disable S3060 // "is" should not be used with "this"
// If the key is non-null, we don't know what it means so pass through to the inner service
return key is null && this is TService service ? service : InnerGenerator.GetService<TService>(key);
#pragma warning restore S3060
_ = Throw.IfNull(serviceType);
// If the key is non-null, we don't know what it means so pass through to the inner service.
return
serviceKey is null && serviceType.IsInstanceOfType(this) ? this :
InnerGenerator.GetService(serviceType, serviceKey);
}
}

Просмотреть файл

@ -15,6 +15,43 @@ namespace Microsoft.Extensions.AI;
/// <summary>Provides a collection of static methods for extending <see cref="IEmbeddingGenerator{TInput,TEmbedding}"/> instances.</summary>
public static class EmbeddingGeneratorExtensions
{
/// <summary>Asks the <see cref="IEmbeddingGenerator{TInput,TEmbedding}"/> for an object of type <typeparamref name="TService"/>.</summary>
/// <typeparam name="TInput">The type from which embeddings will be generated.</typeparam>
/// <typeparam name="TEmbedding">The numeric type of the embedding data.</typeparam>
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
/// <param name="generator">The generator.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the
/// <see cref="IEmbeddingGenerator{TInput,TEmbedding}"/>, including itself or any services it might be wrapping.
/// </remarks>
public static TService? GetService<TInput, TEmbedding, TService>(this IEmbeddingGenerator<TInput, TEmbedding> generator, object? serviceKey = null)
where TEmbedding : Embedding
{
_ = Throw.IfNull(generator);
return (TService?)generator.GetService(typeof(TService), serviceKey);
}
// The following overload exists purely to work around the lack of partial generic type inference.
// Given an IEmbeddingGenerator<TInput, TEmbedding> generator, to call GetService with TService, you still need
// to re-specify both TInput and TEmbedding, e.g. generator.GetService<string, Embedding<float>, TService>.
// The case of string/Embedding<float> is by far the most common case today, so this overload exists as an
// accelerator to allow it to be written simply as generator.GetService<TService>.
/// <summary>Asks the <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/> for an object of type <typeparamref name="TService"/>.</summary>
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
/// <param name="generator">The generator.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the
/// <see cref="IEmbeddingGenerator{TInput,TEmbedding}"/>, including itself or any services it might be wrapping.
/// </remarks>
public static TService? GetService<TService>(this IEmbeddingGenerator<string, Embedding<float>> generator, object? serviceKey = null) =>
GetService<string, Embedding<float>, TService>(generator, serviceKey);
/// <summary>Generates an embedding vector from the specified <paramref name="value"/>.</summary>
/// <typeparam name="TInput">The type from which embeddings will be generated.</typeparam>
/// <typeparam name="TEmbedding">The numeric type of the embedding data.</typeparam>

Просмотреть файл

@ -11,7 +11,7 @@ public class EmbeddingGeneratorMetadata
/// <summary>Initializes a new instance of the <see cref="EmbeddingGeneratorMetadata"/> class.</summary>
/// <param name="providerName">The name of the embedding generation provider, if applicable.</param>
/// <param name="providerUri">The URL for accessing the embedding generation provider, if applicable.</param>
/// <param name="modelId">The id of the embedding generation model used, if applicable.</param>
/// <param name="modelId">The ID of the embedding generation model used, if applicable.</param>
/// <param name="dimensions">The number of dimensions in vectors produced by this generator, if applicable.</param>
public EmbeddingGeneratorMetadata(string? providerName = null, Uri? providerUri = null, string? modelId = null, int? dimensions = null)
{
@ -27,8 +27,8 @@ public class EmbeddingGeneratorMetadata
/// <summary>Gets the URL for accessing the embedding generation provider.</summary>
public Uri? ProviderUri { get; }
/// <summary>Gets the id of the model used by this embedding generation provider.</summary>
/// <remarks>This may be null if either the name is unknown or there are multiple possible models associated with this instance.</remarks>
/// <summary>Gets the ID of the model used by this embedding generation provider.</summary>
/// <remarks>This value can be null if either the name is unknown or there are multiple possible models associated with this instance.</remarks>
public string? ModelId { get; }
/// <summary>Gets the number of dimensions in the embeddings produced by this instance.</summary>

Просмотреть файл

@ -40,14 +40,13 @@ public interface IEmbeddingGenerator<TInput, TEmbedding> : IDisposable
/// <summary>Gets metadata that describes the <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/>.</summary>
EmbeddingGeneratorMetadata Metadata { get; }
/// <summary>Asks the <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/> for an object of type <typeparamref name="TService"/>.</summary>
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
/// <param name="key">An optional key that may be used to help identify the target service.</param>
/// <summary>Asks the <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/> for an object of the specified type <paramref name="serviceType"/>.</summary>
/// <param name="serviceType">The type of object being requested.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly-typed services that may be provided by the <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/>,
/// including itself or any services it might be wrapping.
/// The purpose of this method is to allow for the retrieval of strongly typed services that might be provided by the
/// <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/>, including itself or any services it might be wrapping.
/// </remarks>
TService? GetService<TService>(object? key = null)
where TService : class;
object? GetService(Type serviceType, object? serviceKey = null);
}

Просмотреть файл

@ -73,7 +73,7 @@ public sealed class AIFunctionMetadata
}
/// <summary>Gets the metadata for the parameters to the function.</summary>
/// <remarks>If the function has no parameters, the returned list will be empty.</remarks>
/// <remarks>If the function has no parameters, the returned list is empty.</remarks>
public IReadOnlyList<AIFunctionParameterMetadata> Parameters
{
get => _parameters;
@ -93,7 +93,7 @@ public sealed class AIFunctionMetadata
}
/// <summary>Gets parameter metadata for the return parameter.</summary>
/// <remarks>If the function has no return parameter, the returned value will be a default instance of a <see cref="AIFunctionReturnParameterMetadata"/>.</remarks>
/// <remarks>If the function has no return parameter, the value is a default instance of an <see cref="AIFunctionReturnParameterMetadata"/>.</remarks>
public AIFunctionReturnParameterMetadata ReturnParameter
{
get => _returnParameter;
@ -107,6 +107,6 @@ public sealed class AIFunctionMetadata
init => _additionalProperties = Throw.IfNull(value);
}
/// <summary>Gets a <see cref="JsonSerializerOptions"/> that may be used to marshal function parameters.</summary>
/// <summary>Gets a <see cref="JsonSerializerOptions"/> that can be used to marshal function parameters.</summary>
public JsonSerializerOptions? JsonSerializerOptions { get; init; }
}

Просмотреть файл

@ -7,7 +7,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Provides read-only metadata for a <see cref="AIFunction"/> parameter.
/// Provides read-only metadata for an <see cref="AIFunction"/> parameter.
/// </summary>
public sealed class AIFunctionParameterMetadata
{
@ -24,7 +24,7 @@ public sealed class AIFunctionParameterMetadata
/// <summary>Initializes a new instance of the <see cref="AIFunctionParameterMetadata"/> class as a copy of another <see cref="AIFunctionParameterMetadata"/>.</summary>
/// <exception cref="ArgumentNullException">The <paramref name="metadata"/> was null.</exception>
/// <remarks>This creates a shallow clone of <paramref name="metadata"/>.</remarks>
/// <remarks>This constructor creates a shallow clone of <paramref name="metadata"/>.</remarks>
public AIFunctionParameterMetadata(AIFunctionParameterMetadata metadata)
{
_ = Throw.IfNull(metadata);

Просмотреть файл

@ -7,7 +7,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Provides read-only metadata for a <see cref="AIFunction"/>'s return parameter.
/// Provides read-only metadata for an <see cref="AIFunction"/>'s return parameter.
/// </summary>
public sealed class AIFunctionReturnParameterMetadata
{

Просмотреть файл

@ -212,6 +212,22 @@ IChatClient client = new ChatClientBuilder()
Console.WriteLine((await client.CompleteAsync("What is AI?")).Message);
```
#### Options
Every call to `CompleteAsync` or `CompleteStreamingAsync` may optionally supply a `ChatOptions` instance containing additional parameters for the operation. The most common parameters that are common amongst AI models and services show up as strongly-typed properties on the type, such as `ChatOptions.Temperature`. Other parameters may be supplied by name in a weakly-typed manner via the `ChatOptions.AdditionalProperties` dictionary.
Options may also be baked into an `IChatClient` via the `ConfigureOptions` extension method on `ChatClientBuilder`. This delegating client wraps another client and invokes the supplied delegate to populate a `ChatOptions` instance for every call. For example, to ensure that the `ChatOptions.ModelId` property defaults to a particular model name, code like the following may be used:
```csharp
using Microsoft.Extensions.AI;
IChatClient client = new ChatClientBuilder()
.ConfigureOptions(options => options.ModelId ??= "phi3")
.Use(new OllamaChatClient(new Uri("http://localhost:11434")));
Console.WriteLine(await client.CompleteAsync("What is AI?")); // will request "phi3"
Console.WriteLine(await client.CompleteAsync("What is AI?", new() { ModelId = "llama3.1" })); // will request "llama3.1"
```
#### Pipelines of Functionality
All of these `IChatClient`s may be layered, creating a pipeline of any number of components that all add additional functionality. Such components may come from `Microsoft.Extensions.AI`, may come from other NuGet packages, or may be your own custom implementations that augment the behavior in whatever ways you need.

Просмотреть файл

@ -4,7 +4,7 @@
namespace Microsoft.Extensions.AI;
/// <summary>
/// An options class for configuring the behavior of <see cref="AIJsonUtilities"/> JSON schema creation functionality.
/// Provides options for configuring the behavior of <see cref="AIJsonUtilities"/> JSON schema creation functionality.
/// </summary>
public sealed class AIJsonSchemaCreateOptions
{
@ -16,15 +16,36 @@ public sealed class AIJsonSchemaCreateOptions
/// <summary>
/// Gets a value indicating whether to include the type keyword in inferred schemas for .NET enums.
/// </summary>
public bool IncludeTypeInEnumSchemas { get; init; }
public bool IncludeTypeInEnumSchemas { get; init; } = true;
/// <summary>
/// Gets a value indicating whether to generate schemas with the additionalProperties set to false for .NET objects.
/// </summary>
public bool DisallowAdditionalProperties { get; init; }
public bool DisallowAdditionalProperties { get; init; } = true;
/// <summary>
/// Gets a value indicating whether to include the $schema keyword in inferred schemas.
/// </summary>
public bool IncludeSchemaKeyword { get; init; }
/// <summary>
/// Gets a value indicating whether to mark all properties as required in the schema.
/// </summary>
public bool RequireAllProperties { get; init; } = true;
/// <summary>
/// Gets a value indicating whether to filter keywords that are disallowed by certain AI vendors.
/// </summary>
/// <remarks>
/// Filters a number of non-essential schema keywords that are not yet supported by some AI vendors.
/// These include:
/// <list type="bullet">
/// <item>The "minLength", "maxLength", "pattern", and "format" keywords.</item>
/// <item>The "minimum", "maximum", and "multipleOf" keywords.</item>
/// <item>The "patternProperties", "unevaluatedProperties", "propertyNames", "minProperties", and "maxProperties" keywords.</item>
/// <item>The "unevaluatedItems", "contains", "minContains", "maxContains", "minItems", "maxItems", and "uniqueItems" keywords.</item>
/// </list>
/// See also https://platform.openai.com/docs/guides/structured-outputs#some-type-specific-keywords-are-not-yet-supported.
/// </remarks>
public bool FilterDisallowedKeywords { get; init; } = true;
}

Просмотреть файл

@ -3,6 +3,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
#if !NET9_0_OR_GREATER
@ -30,7 +31,9 @@ using FunctionParameterKey = (
object? DefaultValue,
bool IncludeSchemaUri,
bool DisallowAdditionalProperties,
bool IncludeTypeInEnumSchemas);
bool IncludeTypeInEnumSchemas,
bool RequireAllProperties,
bool FilterDisallowedKeywords);
namespace Microsoft.Extensions.AI;
@ -52,6 +55,10 @@ public static partial class AIJsonUtilities
/// <summary>Gets a JSON schema only accepting null values.</summary>
private static readonly JsonElement _nullJsonSchema = ParseJsonElement("""{"type":"null"}"""u8);
// List of keywords used by JsonSchemaExporter but explicitly disallowed by some AI vendors.
// cf. https://platform.openai.com/docs/guides/structured-outputs#some-type-specific-keywords-are-not-yet-supported
private static readonly string[] _schemaKeywordsDisallowedByAIVendors = ["minLength", "maxLength", "pattern", "format"];
/// <summary>
/// Determines a JSON schema for the provided parameter metadata.
/// </summary>
@ -95,7 +102,7 @@ public static partial class AIJsonUtilities
/// <param name="type">The type of the parameter.</param>
/// <param name="parameterName">The name of the parameter.</param>
/// <param name="description">The description of the parameter.</param>
/// <param name="hasDefaultValue">Whether the parameter is optional.</param>
/// <param name="hasDefaultValue"><see langword="true"/> if the parameter is optional; otherwise, <see langword="false"/>.</param>
/// <param name="defaultValue">The default value of the optional parameter, if applicable.</param>
/// <param name="serializerOptions">The options used to extract the schema from the specified type.</param>
/// <param name="inferenceOptions">The options controlling schema inference.</param>
@ -122,7 +129,9 @@ public static partial class AIJsonUtilities
defaultValue,
IncludeSchemaUri: false,
inferenceOptions.DisallowAdditionalProperties,
inferenceOptions.IncludeTypeInEnumSchemas);
inferenceOptions.IncludeTypeInEnumSchemas,
inferenceOptions.RequireAllProperties,
inferenceOptions.FilterDisallowedKeywords);
return GetJsonSchemaCached(serializerOptions, key);
}
@ -130,7 +139,7 @@ public static partial class AIJsonUtilities
/// <summary>Creates a JSON schema for the specified type.</summary>
/// <param name="type">The type for which to generate the schema.</param>
/// <param name="description">The description of the parameter.</param>
/// <param name="hasDefaultValue">Whether the parameter is optional.</param>
/// <param name="hasDefaultValue"><see langword="true"/> if the parameter is optional; otherwise, <see langword="false"/>.</param>
/// <param name="defaultValue">The default value of the optional parameter, if applicable.</param>
/// <param name="serializerOptions">The options used to extract the schema from the specified type.</param>
/// <param name="inferenceOptions">The options controlling schema inference.</param>
@ -154,7 +163,9 @@ public static partial class AIJsonUtilities
defaultValue,
inferenceOptions.IncludeSchemaKeyword,
inferenceOptions.DisallowAdditionalProperties,
inferenceOptions.IncludeTypeInEnumSchemas);
inferenceOptions.IncludeTypeInEnumSchemas,
inferenceOptions.RequireAllProperties,
inferenceOptions.FilterDisallowedKeywords);
return GetJsonSchemaCached(serializerOptions, key);
}
@ -242,6 +253,7 @@ public static partial class AIJsonUtilities
const string PatternPropertyName = "pattern";
const string EnumPropertyName = "enum";
const string PropertiesPropertyName = "properties";
const string RequiredPropertyName = "required";
const string AdditionalPropertiesPropertyName = "additionalProperties";
const string DefaultPropertyName = "default";
const string RefPropertyName = "$ref";
@ -275,11 +287,35 @@ public static partial class AIJsonUtilities
}
// Disallow additional properties in object schemas
if (key.DisallowAdditionalProperties && objSchema.ContainsKey(PropertiesPropertyName) && !objSchema.ContainsKey(AdditionalPropertiesPropertyName))
if (key.DisallowAdditionalProperties &&
objSchema.ContainsKey(PropertiesPropertyName) &&
!objSchema.ContainsKey(AdditionalPropertiesPropertyName))
{
objSchema.Add(AdditionalPropertiesPropertyName, (JsonNode)false);
}
// Mark all properties as required
if (key.RequireAllProperties &&
objSchema.TryGetPropertyValue(PropertiesPropertyName, out JsonNode? properties) &&
properties is JsonObject propertiesObj)
{
_ = objSchema.TryGetPropertyValue(RequiredPropertyName, out JsonNode? required);
if (required is not JsonArray { } requiredArray || requiredArray.Count != propertiesObj.Count)
{
requiredArray = [.. propertiesObj.Select(prop => prop.Key)];
objSchema[RequiredPropertyName] = requiredArray;
}
}
// Filter potentially disallowed keywords.
if (key.FilterDisallowedKeywords)
{
foreach (string keyword in _schemaKeywordsDisallowedByAIVendors)
{
_ = objSchema.Remove(keyword);
}
}
// Some consumers of the JSON schema, including Ollama as of v0.3.13, don't understand
// schemas with "type": [...], and only understand "type" being a single value.
// STJ represents .NET integer types as ["string", "integer"], which will then lead to an error.

Просмотреть файл

@ -18,7 +18,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
/// <summary>An <see cref="IChatClient"/> for an Azure AI Inference <see cref="ChatCompletionsClient"/>.</summary>
/// <summary>Represents an <see cref="IChatClient"/> for an Azure AI Inference <see cref="ChatCompletionsClient"/>.</summary>
public sealed class AzureAIInferenceChatClient : IChatClient
{
/// <summary>A default schema to use when a parameter lacks a pre-defined schema.</summary>
@ -29,7 +29,7 @@ public sealed class AzureAIInferenceChatClient : IChatClient
/// <summary>Initializes a new instance of the <see cref="AzureAIInferenceChatClient"/> class for the specified <see cref="ChatCompletionsClient"/>.</summary>
/// <param name="chatCompletionsClient">The underlying client.</param>
/// <param name="modelId">The id of the model to use. If null, it may be provided per request via <see cref="ChatOptions.ModelId"/>.</param>
/// <param name="modelId">The ID of the model to use. If null, it can be provided per request via <see cref="ChatOptions.ModelId"/>.</param>
public AzureAIInferenceChatClient(ChatCompletionsClient chatCompletionsClient, string? modelId = null)
{
_ = Throw.IfNull(chatCompletionsClient);
@ -57,10 +57,16 @@ public sealed class AzureAIInferenceChatClient : IChatClient
public ChatClientMetadata Metadata { get; }
/// <inheritdoc />
public TService? GetService<TService>(object? key = null)
where TService : class =>
typeof(TService) == typeof(ChatCompletionsClient) ? (TService?)(object?)_chatCompletionsClient :
this as TService;
public object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);
return
serviceKey is not null ? null :
serviceType == typeof(ChatCompletionsClient) ? _chatCompletionsClient :
serviceType.IsInstanceOfType(this) ? this :
null;
}
/// <inheritdoc />
public async Task<ChatCompletion> CompleteAsync(
@ -295,7 +301,7 @@ public sealed class AzureAIInferenceChatClient : IChatClient
}
}
// These properties are strongly-typed on ChatOptions but not on ChatCompletionsOptions.
// These properties are strongly typed on ChatOptions but not on ChatCompletionsOptions.
if (options.TopK is int topK)
{
result.AdditionalProperties["top_k"] = new BinaryData(JsonSerializer.SerializeToUtf8Bytes(topK, JsonContext.Default.Int32));

Просмотреть файл

@ -21,7 +21,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
/// <summary>An <see cref="IEmbeddingGenerator{String, Embedding}"/> for an Azure.AI.Inference <see cref="EmbeddingsClient"/>.</summary>
/// <summary>Represents an <see cref="IEmbeddingGenerator{String, Embedding}"/> for an Azure.AI.Inference <see cref="EmbeddingsClient"/>.</summary>
public sealed class AzureAIInferenceEmbeddingGenerator :
IEmbeddingGenerator<string, Embedding<float>>
{
@ -34,8 +34,8 @@ public sealed class AzureAIInferenceEmbeddingGenerator :
/// <summary>Initializes a new instance of the <see cref="AzureAIInferenceEmbeddingGenerator"/> class.</summary>
/// <param name="embeddingsClient">The underlying client.</param>
/// <param name="modelId">
/// The id of the model to use. This may also be overridden per request via <see cref="EmbeddingGenerationOptions.ModelId"/>.
/// Either this parameter or <see cref="EmbeddingGenerationOptions.ModelId"/> must provide a valid model id.
/// The ID of the model to use. This can also be overridden per request via <see cref="EmbeddingGenerationOptions.ModelId"/>.
/// Either this parameter or <see cref="EmbeddingGenerationOptions.ModelId"/> must provide a valid model ID.
/// </param>
/// <param name="dimensions">The number of dimensions to generate in each embedding.</param>
public AzureAIInferenceEmbeddingGenerator(
@ -70,10 +70,16 @@ public sealed class AzureAIInferenceEmbeddingGenerator :
public EmbeddingGeneratorMetadata Metadata { get; }
/// <inheritdoc />
public TService? GetService<TService>(object? key = null)
where TService : class =>
typeof(TService) == typeof(EmbeddingsClient) ? (TService)(object)_embeddingsClient :
this as TService;
public object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);
return
serviceKey is not null ? null :
serviceType == typeof(EmbeddingsClient) ? _embeddingsClient :
serviceType.IsInstanceOfType(this) ? this :
null;
}
/// <inheritdoc />
public async Task<GeneratedEmbeddings<Embedding<float>>> GenerateAsync(

Просмотреть файл

@ -10,17 +10,17 @@ public static class AzureAIInferenceExtensions
{
/// <summary>Gets an <see cref="IChatClient"/> for use with this <see cref="ChatCompletionsClient"/>.</summary>
/// <param name="chatCompletionsClient">The client.</param>
/// <param name="modelId">The id of the model to use. If null, it may be provided per request via <see cref="ChatOptions.ModelId"/>.</param>
/// <returns>An <see cref="IChatClient"/> that may be used to converse via the <see cref="ChatCompletionsClient"/>.</returns>
/// <param name="modelId">The ID of the model to use. If null, it can be provided per request via <see cref="ChatOptions.ModelId"/>.</param>
/// <returns>An <see cref="IChatClient"/> that can be used to converse via the <see cref="ChatCompletionsClient"/>.</returns>
public static IChatClient AsChatClient(
this ChatCompletionsClient chatCompletionsClient, string? modelId = null) =>
new AzureAIInferenceChatClient(chatCompletionsClient, modelId);
/// <summary>Gets an <see cref="IEmbeddingGenerator{String, Single}"/> for use with this <see cref="EmbeddingsClient"/>.</summary>
/// <param name="embeddingsClient">The client.</param>
/// <param name="modelId">The id of the model to use. If null, it may be provided per request via <see cref="ChatOptions.ModelId"/>.</param>
/// <param name="modelId">The ID of the model to use. If null, it can be provided per request via <see cref="ChatOptions.ModelId"/>.</param>
/// <param name="dimensions">The number of dimensions to generate in each embedding.</param>
/// <returns>An <see cref="IEmbeddingGenerator{String, Embedding}"/> that may be used to generate embeddings via the <see cref="EmbeddingsClient"/>.</returns>
/// <returns>An <see cref="IEmbeddingGenerator{String, Embedding}"/> that can be used to generate embeddings via the <see cref="EmbeddingsClient"/>.</returns>
public static IEmbeddingGenerator<string, Embedding<float>> AsEmbeddingGenerator(
this EmbeddingsClient embeddingsClient, string? modelId = null, int? dimensions = null) =>
new AzureAIInferenceEmbeddingGenerator(embeddingsClient, modelId, dimensions);

Просмотреть файл

@ -1,5 +1,9 @@
# Release History
## 9.0.0-preview.9.24556.5
- Fixed `AzureAIInferenceEmbeddingGenerator` to respect `EmbeddingGenerationOptions.Dimensions`.
## 9.0.0-preview.9.24525.1
- Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older.

Просмотреть файл

@ -19,7 +19,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
/// <summary>An <see cref="IChatClient"/> for Ollama.</summary>
/// <summary>Represents an <see cref="IChatClient"/> for Ollama.</summary>
public sealed class OllamaChatClient : IChatClient
{
private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement;
@ -33,8 +33,8 @@ public sealed class OllamaChatClient : IChatClient
/// <summary>Initializes a new instance of the <see cref="OllamaChatClient"/> class.</summary>
/// <param name="endpoint">The endpoint URI where Ollama is hosted.</param>
/// <param name="modelId">
/// The id of the model to use. This may also be overridden per request via <see cref="ChatOptions.ModelId"/>.
/// Either this parameter or <see cref="ChatOptions.ModelId"/> must provide a valid model id.
/// The ID of the model to use. This ID can also be overridden per request via <see cref="ChatOptions.ModelId"/>.
/// Either this parameter or <see cref="ChatOptions.ModelId"/> must provide a valid model ID.
/// </param>
/// <param name="httpClient">An <see cref="HttpClient"/> instance to use for HTTP operations.</param>
public OllamaChatClient(string endpoint, string? modelId = null, HttpClient? httpClient = null)
@ -45,8 +45,8 @@ public sealed class OllamaChatClient : IChatClient
/// <summary>Initializes a new instance of the <see cref="OllamaChatClient"/> class.</summary>
/// <param name="endpoint">The endpoint URI where Ollama is hosted.</param>
/// <param name="modelId">
/// The id of the model to use. This may also be overridden per request via <see cref="ChatOptions.ModelId"/>.
/// Either this parameter or <see cref="ChatOptions.ModelId"/> must provide a valid model id.
/// The ID of the model to use. This ID can also be overridden per request via <see cref="ChatOptions.ModelId"/>.
/// Either this parameter or <see cref="ChatOptions.ModelId"/> must provide a valid model ID.
/// </param>
/// <param name="httpClient">An <see cref="HttpClient"/> instance to use for HTTP operations.</param>
public OllamaChatClient(Uri endpoint, string? modelId = null, HttpClient? httpClient = null)
@ -166,9 +166,14 @@ public sealed class OllamaChatClient : IChatClient
}
/// <inheritdoc />
public TService? GetService<TService>(object? key = null)
where TService : class
=> key is null ? this as TService : null;
public object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);
return
serviceKey is null && serviceType.IsInstanceOfType(this) ? this :
null;
}
/// <inheritdoc />
public void Dispose()

Просмотреть файл

@ -12,7 +12,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
/// <summary>An <see cref="IEmbeddingGenerator{String, Embedding}"/> for Ollama.</summary>
/// <summary>Represents an <see cref="IEmbeddingGenerator{String, Embedding}"/> for Ollama.</summary>
public sealed class OllamaEmbeddingGenerator : IEmbeddingGenerator<string, Embedding<float>>
{
/// <summary>The api/embeddings endpoint URI.</summary>
@ -24,8 +24,8 @@ public sealed class OllamaEmbeddingGenerator : IEmbeddingGenerator<string, Embed
/// <summary>Initializes a new instance of the <see cref="OllamaEmbeddingGenerator"/> class.</summary>
/// <param name="endpoint">The endpoint URI where Ollama is hosted.</param>
/// <param name="modelId">
/// The id of the model to use. This may also be overridden per request via <see cref="ChatOptions.ModelId"/>.
/// Either this parameter or <see cref="ChatOptions.ModelId"/> must provide a valid model id.
/// The ID of the model to use. This ID can also be overridden per request via <see cref="ChatOptions.ModelId"/>.
/// Either this parameter or <see cref="ChatOptions.ModelId"/> must provide a valid model ID.
/// </param>
/// <param name="httpClient">An <see cref="HttpClient"/> instance to use for HTTP operations.</param>
public OllamaEmbeddingGenerator(string endpoint, string? modelId = null, HttpClient? httpClient = null)
@ -36,8 +36,8 @@ public sealed class OllamaEmbeddingGenerator : IEmbeddingGenerator<string, Embed
/// <summary>Initializes a new instance of the <see cref="OllamaEmbeddingGenerator"/> class.</summary>
/// <param name="endpoint">The endpoint URI where Ollama is hosted.</param>
/// <param name="modelId">
/// The id of the model to use. This may also be overridden per request via <see cref="ChatOptions.ModelId"/>.
/// Either this parameter or <see cref="ChatOptions.ModelId"/> must provide a valid model id.
/// The ID of the model to use. This ID can also be overridden per request via <see cref="ChatOptions.ModelId"/>.
/// Either this parameter or <see cref="ChatOptions.ModelId"/> must provide a valid model ID.
/// </param>
/// <param name="httpClient">An <see cref="HttpClient"/> instance to use for HTTP operations.</param>
public OllamaEmbeddingGenerator(Uri endpoint, string? modelId = null, HttpClient? httpClient = null)
@ -57,9 +57,14 @@ public sealed class OllamaEmbeddingGenerator : IEmbeddingGenerator<string, Embed
public EmbeddingGeneratorMetadata Metadata { get; }
/// <inheritdoc />
public TService? GetService<TService>(object? key = null)
where TService : class
=> key is null ? this as TService : null;
public object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);
return
serviceKey is null && serviceType.IsInstanceOfType(this) ? this :
null;
}
/// <inheritdoc />
public void Dispose()

Просмотреть файл

@ -16,6 +16,7 @@ using Microsoft.Shared.Diagnostics;
using OpenAI;
using OpenAI.Chat;
#pragma warning disable S1067 // Expressions should not be too complex
#pragma warning disable S1135 // Track uses of "TODO" tags
#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields
#pragma warning disable SA1204 // Static elements should appear before instance elements
@ -23,7 +24,7 @@ using OpenAI.Chat;
namespace Microsoft.Extensions.AI;
/// <summary>An <see cref="IChatClient"/> for an OpenAI <see cref="OpenAIClient"/> or <see cref="OpenAI.Chat.ChatClient"/>.</summary>
/// <summary>Represents an <see cref="IChatClient"/> for an OpenAI <see cref="OpenAIClient"/> or <see cref="OpenAI.Chat.ChatClient"/>.</summary>
public sealed partial class OpenAIChatClient : IChatClient
{
private static readonly JsonElement _defaultParameterSchema = JsonDocument.Parse("{}").RootElement;
@ -85,11 +86,17 @@ public sealed partial class OpenAIChatClient : IChatClient
public ChatClientMetadata Metadata { get; }
/// <inheritdoc />
public TService? GetService<TService>(object? key = null)
where TService : class =>
typeof(TService) == typeof(OpenAIClient) ? (TService?)(object?)_openAIClient :
typeof(TService) == typeof(ChatClient) ? (TService)(object)_chatClient :
this as TService;
public object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);
return
serviceKey is not null ? null :
serviceType == typeof(OpenAIClient) ? _openAIClient :
serviceType == typeof(ChatClient) ? _chatClient :
serviceType.IsInstanceOfType(this) ? this :
null;
}
/// <inheritdoc />
public async Task<ChatCompletion> CompleteAsync(

Просмотреть файл

@ -13,13 +13,13 @@ public static class OpenAIClientExtensions
/// <summary>Gets an <see cref="IChatClient"/> for use with this <see cref="OpenAIClient"/>.</summary>
/// <param name="openAIClient">The client.</param>
/// <param name="modelId">The model.</param>
/// <returns>An <see cref="IChatClient"/> that may be used to converse via the <see cref="OpenAIClient"/>.</returns>
/// <returns>An <see cref="IChatClient"/> that can be used to converse via the <see cref="OpenAIClient"/>.</returns>
public static IChatClient AsChatClient(this OpenAIClient openAIClient, string modelId) =>
new OpenAIChatClient(openAIClient, modelId);
/// <summary>Gets an <see cref="IChatClient"/> for use with this <see cref="ChatClient"/>.</summary>
/// <param name="chatClient">The client.</param>
/// <returns>An <see cref="IChatClient"/> that may be used to converse via the <see cref="ChatClient"/>.</returns>
/// <returns>An <see cref="IChatClient"/> that can be used to converse via the <see cref="ChatClient"/>.</returns>
public static IChatClient AsChatClient(this ChatClient chatClient) =>
new OpenAIChatClient(chatClient);
@ -27,14 +27,14 @@ public static class OpenAIClientExtensions
/// <param name="openAIClient">The client.</param>
/// <param name="modelId">The model to use.</param>
/// <param name="dimensions">The number of dimensions to generate in each embedding.</param>
/// <returns>An <see cref="IEmbeddingGenerator{String, Embedding}"/> that may be used to generate embeddings via the <see cref="EmbeddingClient"/>.</returns>
/// <returns>An <see cref="IEmbeddingGenerator{String, Embedding}"/> that can be used to generate embeddings via the <see cref="EmbeddingClient"/>.</returns>
public static IEmbeddingGenerator<string, Embedding<float>> AsEmbeddingGenerator(this OpenAIClient openAIClient, string modelId, int? dimensions = null) =>
new OpenAIEmbeddingGenerator(openAIClient, modelId, dimensions);
/// <summary>Gets an <see cref="IEmbeddingGenerator{String, Single}"/> for use with this <see cref="EmbeddingClient"/>.</summary>
/// <param name="embeddingClient">The client.</param>
/// <param name="dimensions">The number of dimensions to generate in each embedding.</param>
/// <returns>An <see cref="IEmbeddingGenerator{String, Embedding}"/> that may be used to generate embeddings via the <see cref="EmbeddingClient"/>.</returns>
/// <returns>An <see cref="IEmbeddingGenerator{String, Embedding}"/> that can be used to generate embeddings via the <see cref="EmbeddingClient"/>.</returns>
public static IEmbeddingGenerator<string, Embedding<float>> AsEmbeddingGenerator(this EmbeddingClient embeddingClient, int? dimensions = null) =>
new OpenAIEmbeddingGenerator(embeddingClient, dimensions);
}

Просмотреть файл

@ -11,6 +11,7 @@ using Microsoft.Shared.Diagnostics;
using OpenAI;
using OpenAI.Embeddings;
#pragma warning disable S1067 // Expressions should not be too complex
#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields
namespace Microsoft.Extensions.AI;
@ -95,12 +96,17 @@ public sealed class OpenAIEmbeddingGenerator : IEmbeddingGenerator<string, Embed
public EmbeddingGeneratorMetadata Metadata { get; }
/// <inheritdoc />
public TService? GetService<TService>(object? key = null)
where TService : class
=>
typeof(TService) == typeof(OpenAIClient) ? (TService?)(object?)_openAIClient :
typeof(TService) == typeof(EmbeddingClient) ? (TService)(object)_embeddingClient :
this as TService;
public object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);
return
serviceKey is not null ? null :
serviceType == typeof(OpenAIClient) ? _openAIClient :
serviceType == typeof(EmbeddingClient) ? _embeddingClient :
serviceType.IsInstanceOfType(this) ? this :
null;
}
/// <inheritdoc />
public async Task<GeneratedEmbeddings<Embedding<float>>> GenerateAsync(IEnumerable<string> values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default)

Просмотреть файл

@ -1,5 +1,9 @@
# Release History
## 9.0.0-preview.9.24556.5
- Added `UseEmbeddingGenerationOptions` and corresponding `ConfigureOptionsEmbeddingGenerator`.
## 9.0.0-preview.9.24525.1
- Added new `AIJsonUtilities` and `AIJsonSchemaCreateOptions` classes.

Просмотреть файл

@ -3,7 +3,6 @@
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
@ -48,13 +47,12 @@ public abstract class CachingChatClient : DelegatingChatClient
// concurrent callers might trigger duplicate requests, but that's acceptable.
var cacheKey = GetCacheKey(false, chatMessages, options);
if (await ReadCacheAsync(cacheKey, cancellationToken).ConfigureAwait(false) is ChatCompletion existing)
if (await ReadCacheAsync(cacheKey, cancellationToken).ConfigureAwait(false) is not { } result)
{
return existing;
result = await base.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false);
await WriteCacheAsync(cacheKey, result, cancellationToken).ConfigureAwait(false);
}
var result = await base.CompleteAsync(chatMessages, options, cancellationToken).ConfigureAwait(false);
await WriteCacheAsync(cacheKey, result, cancellationToken).ConfigureAwait(false);
return result;
}
@ -64,127 +62,59 @@ public abstract class CachingChatClient : DelegatingChatClient
{
_ = Throw.IfNull(chatMessages);
var cacheKey = GetCacheKey(true, chatMessages, options);
if (await ReadCacheStreamingAsync(cacheKey, cancellationToken).ConfigureAwait(false) is { } existingChunks)
if (CoalesceStreamingUpdates)
{
// Yield all of the cached items.
foreach (var chunk in existingChunks)
// When coalescing updates, we cache non-streaming results coalesced from streaming ones. That means
// we make a streaming request, yielding those results, but then convert those into a non-streaming
// result and cache it. When we get a cache hit, we yield the non-streaming result as a streaming one.
var cacheKey = GetCacheKey(true, chatMessages, options);
if (await ReadCacheAsync(cacheKey, cancellationToken).ConfigureAwait(false) is { } chatCompletion)
{
yield return chunk;
// Yield all of the cached items.
foreach (var chunk in chatCompletion.ToStreamingChatCompletionUpdates())
{
yield return chunk;
}
}
else
{
// Yield and store all of the items.
List<StreamingChatCompletionUpdate> capturedItems = [];
await foreach (var chunk in base.CompleteStreamingAsync(chatMessages, options, cancellationToken).ConfigureAwait(false))
{
capturedItems.Add(chunk);
yield return chunk;
}
// Write the captured items to the cache as a non-streaming result.
await WriteCacheAsync(cacheKey, capturedItems.ToChatCompletion(), cancellationToken).ConfigureAwait(false);
}
}
else
{
// Yield and store all of the items.
List<StreamingChatCompletionUpdate> capturedItems = [];
await foreach (var chunk in base.CompleteStreamingAsync(chatMessages, options, cancellationToken).ConfigureAwait(false))
var cacheKey = GetCacheKey(true, chatMessages, options);
if (await ReadCacheStreamingAsync(cacheKey, cancellationToken).ConfigureAwait(false) is { } existingChunks)
{
capturedItems.Add(chunk);
yield return chunk;
}
// If the caching client is configured to coalesce streaming updates, do so now within the capturedItems list.
if (CoalesceStreamingUpdates)
{
StringBuilder coalescedText = new();
// Iterate through all of the items in the list looking for contiguous items that can be coalesced.
for (int startInclusive = 0; startInclusive < capturedItems.Count; startInclusive++)
// Yield all of the cached items.
foreach (var chunk in existingChunks)
{
// If an item isn't generally coalescable, skip it.
StreamingChatCompletionUpdate update = capturedItems[startInclusive];
if (update.ChoiceIndex != 0 ||
update.Contents.Count != 1 ||
update.Contents[0] is not TextContent textContent)
{
continue;
}
// We found a coalescable item. Look for more contiguous items that are also coalescable with it.
int endExclusive = startInclusive + 1;
for (; endExclusive < capturedItems.Count; endExclusive++)
{
StreamingChatCompletionUpdate next = capturedItems[endExclusive];
if (next.ChoiceIndex != 0 ||
next.Contents.Count != 1 ||
next.Contents[0] is not TextContent ||
// changing role or author would be really strange, but check anyway
(update.Role is not null && next.Role is not null && update.Role != next.Role) ||
(update.AuthorName is not null && next.AuthorName is not null && update.AuthorName != next.AuthorName))
{
break;
}
}
// If we couldn't find anything to coalesce, there's nothing to do.
if (endExclusive - startInclusive <= 1)
{
continue;
}
// We found a coalescable run of items. Create a new node to represent the run. We create a new one
// rather than reappropriating one of the existing ones so as not to mutate an item already yielded.
_ = coalescedText.Clear().Append(capturedItems[startInclusive].Text);
TextContent coalescedContent = new(null) // will patch the text after examining all items in the run
{
AdditionalProperties = textContent.AdditionalProperties?.Clone(),
};
StreamingChatCompletionUpdate coalesced = new()
{
AdditionalProperties = update.AdditionalProperties?.Clone(),
AuthorName = update.AuthorName,
CompletionId = update.CompletionId,
Contents = [coalescedContent],
CreatedAt = update.CreatedAt,
FinishReason = update.FinishReason,
ModelId = update.ModelId,
Role = update.Role,
// Explicitly don't include RawRepresentation. It's not applicable if one update ends up being used
// to represent multiple, and it won't be serialized anyway.
};
// Replace the starting node with the coalesced node.
capturedItems[startInclusive] = coalesced;
// Now iterate through all the rest of the updates in the run, updating the coalesced node with relevant properties,
// and nulling out the nodes along the way. We do this rather than removing the entry in order to avoid an O(N^2) operation.
// We'll remove all the null entries at the end of the loop, using RemoveAll to do so, which can remove all of
// the nulls in a single O(N) pass.
for (int i = startInclusive + 1; i < endExclusive; i++)
{
// Grab the next item.
StreamingChatCompletionUpdate next = capturedItems[i];
capturedItems[i] = null!;
var nextContent = (TextContent)next.Contents[0];
_ = coalescedText.Append(nextContent.Text);
coalesced.AuthorName ??= next.AuthorName;
coalesced.CompletionId ??= next.CompletionId;
coalesced.CreatedAt ??= next.CreatedAt;
coalesced.FinishReason ??= next.FinishReason;
coalesced.ModelId ??= next.ModelId;
coalesced.Role ??= next.Role;
}
// Complete the coalescing by patching the text of the coalesced node.
coalesced.Text = coalescedText.ToString();
// Jump to the last update in the run, so that when we loop around and bump ahead,
// we're at the next update just after the run.
startInclusive = endExclusive - 1;
yield return chunk;
}
}
else
{
// Yield and store all of the items.
List<StreamingChatCompletionUpdate> capturedItems = [];
await foreach (var chunk in base.CompleteStreamingAsync(chatMessages, options, cancellationToken).ConfigureAwait(false))
{
capturedItems.Add(chunk);
yield return chunk;
}
// Remove all of the null slots left over from the coalescing process.
_ = capturedItems.RemoveAll(u => u is null);
// Write the captured items to the cache.
await WriteCacheStreamingAsync(cacheKey, capturedItems, cancellationToken).ConfigureAwait(false);
}
// Write the captured items to the cache.
await WriteCacheStreamingAsync(cacheKey, capturedItems, cancellationToken).ConfigureAwait(false);
}
}

Просмотреть файл

@ -45,9 +45,11 @@ public class ChatCompletion<T> : ChatCompletion
/// <summary>
/// Gets the result of the chat completion as an instance of <typeparamref name="T"/>.
/// </summary>
/// <remarks>
/// If the response did not contain JSON, or if deserialization fails, this property will throw.
/// To avoid exceptions, use <see cref="TryGetResult(out T)"/> instead.
/// </summary>
/// </remarks>
public T Result
{
get
@ -66,7 +68,7 @@ public class ChatCompletion<T> : ChatCompletion
/// <summary>
/// Attempts to deserialize the result to produce an instance of <typeparamref name="T"/>.
/// </summary>
/// <param name="result">The result.</param>
/// <param name="result">When this method returns, contains the result.</param>
/// <returns><see langword="true"/> if the result was produced, otherwise <see langword="false"/>.</returns>
public bool TryGetResult([NotNullWhen(true)] out T? result)
{
@ -106,8 +108,10 @@ public class ChatCompletion<T> : ChatCompletion
/// <summary>
/// Gets or sets a value indicating whether the JSON schema has an extra object wrapper.
/// This is required for any non-JSON-object-typed values such as numbers, enum values, or arrays.
/// </summary>
/// <remarks>
/// The wrapper is required for any non-JSON-object-typed values such as numbers, enum values, and arrays.
/// </remarks>
internal bool IsWrappedInObject { get; set; }
private string? GetResultAsJson()

Просмотреть файл

@ -8,67 +8,54 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
#pragma warning disable SA1629 // Documentation text should end with a period
namespace Microsoft.Extensions.AI;
/// <summary>A delegating chat client that updates or replaces the <see cref="ChatOptions"/> used by the remainder of the pipeline.</summary>
/// <remarks>
/// <para>
/// The configuration callback is invoked with the caller-supplied <see cref="ChatOptions"/> instance. To override the caller-supplied options
/// with a new instance, the callback may simply return that new instance, for example <c>_ => new ChatOptions() { MaxTokens = 1000 }</c>. To provide
/// a new instance only if the caller-supplied instance is <see langword="null"/>, the callback may conditionally return a new instance, for example
/// <c>options => options ?? new ChatOptions() { MaxTokens = 1000 }</c>. Any changes to the caller-provided options instance will persist on the
/// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance
/// and mutating the clone, for example:
/// <c>
/// options =>
/// {
/// var newOptions = options?.Clone() ?? new();
/// newOptions.MaxTokens = 1000;
/// return newOptions;
/// }
/// </c>
/// </para>
/// <para>
/// The callback may return <see langword="null"/>, in which case a <see langword="null"/> options will be passed to the next client in the pipeline.
/// </para>
/// <para>
/// The provided implementation of <see cref="IChatClient"/> is thread-safe for concurrent use so long as the employed configuration
/// callback is also thread-safe for concurrent requests. If callers employ a shared options instance, care should be taken in the
/// configuration callback, as multiple calls to it may end up running in parallel with the same options instance.
/// </para>
/// </remarks>
/// <summary>Represents a delegating chat client that configures a <see cref="ChatOptions"/> instance used by the remainder of the pipeline.</summary>
public sealed class ConfigureOptionsChatClient : DelegatingChatClient
{
/// <summary>The callback delegate used to configure options.</summary>
private readonly Func<ChatOptions?, ChatOptions?> _configureOptions;
private readonly Action<ChatOptions> _configureOptions;
/// <summary>Initializes a new instance of the <see cref="ConfigureOptionsChatClient"/> class with the specified <paramref name="configureOptions"/> callback.</summary>
/// <summary>Initializes a new instance of the <see cref="ConfigureOptionsChatClient"/> class with the specified <paramref name="configure"/> callback.</summary>
/// <param name="innerClient">The inner client.</param>
/// <param name="configureOptions">
/// The delegate to invoke to configure the <see cref="ChatOptions"/> instance. It is passed the caller-supplied <see cref="ChatOptions"/>
/// instance and should return the configured <see cref="ChatOptions"/> instance to use.
/// <param name="configure">
/// The delegate to invoke to configure the <see cref="ChatOptions"/> instance. It is passed a clone of the caller-supplied <see cref="ChatOptions"/> instance
/// (or a newly constructed instance if the caller-supplied instance is <see langword="null"/>).
/// </param>
public ConfigureOptionsChatClient(IChatClient innerClient, Func<ChatOptions?, ChatOptions?> configureOptions)
/// <remarks>
/// The <paramref name="configure"/> delegate is passed either a new instance of <see cref="ChatOptions"/> if
/// the caller didn't supply a <see cref="ChatOptions"/> instance, or a clone (via <see cref="ChatOptions.Clone"/> of the caller-supplied
/// instance if one was supplied.
/// </remarks>
public ConfigureOptionsChatClient(IChatClient innerClient, Action<ChatOptions> configure)
: base(innerClient)
{
_configureOptions = Throw.IfNull(configureOptions);
_configureOptions = Throw.IfNull(configure);
}
/// <inheritdoc/>
public override async Task<ChatCompletion> CompleteAsync(IList<ChatMessage> chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
return await base.CompleteAsync(chatMessages, _configureOptions(options), cancellationToken).ConfigureAwait(false);
return await base.CompleteAsync(chatMessages, Configure(options), cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public override async IAsyncEnumerable<StreamingChatCompletionUpdate> CompleteStreamingAsync(
IList<ChatMessage> chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var update in base.CompleteStreamingAsync(chatMessages, _configureOptions(options), cancellationToken).ConfigureAwait(false))
await foreach (var update in base.CompleteStreamingAsync(chatMessages, Configure(options), cancellationToken).ConfigureAwait(false))
{
yield return update;
}
}
/// <summary>Creates and configures the <see cref="ChatOptions"/> to pass along to the inner client.</summary>
private ChatOptions Configure(ChatOptions? options)
{
options = options?.Clone() ?? new();
_configureOptions(options);
return options;
}
}

Просмотреть файл

@ -12,41 +12,25 @@ namespace Microsoft.Extensions.AI;
public static class ConfigureOptionsChatClientBuilderExtensions
{
/// <summary>
/// Adds a callback that updates or replaces <see cref="ChatOptions"/>. This can be used to set default options.
/// Adds a callback that configures a <see cref="ChatOptions"/> to be passed to the next client in the pipeline.
/// </summary>
/// <param name="builder">The <see cref="ChatClientBuilder"/>.</param>
/// <param name="configureOptions">
/// The delegate to invoke to configure the <see cref="ChatOptions"/> instance. It is passed the caller-supplied <see cref="ChatOptions"/>
/// instance and should return the configured <see cref="ChatOptions"/> instance to use.
/// <param name="configure">
/// The delegate to invoke to configure the <see cref="ChatOptions"/> instance.
/// It is passed a clone of the caller-supplied <see cref="ChatOptions"/> instance (or a newly constructed instance if the caller-supplied instance is <see langword="null"/>).
/// </param>
/// <returns>The <paramref name="builder"/>.</returns>
/// <remarks>
/// <para>
/// The configuration callback is invoked with the caller-supplied <see cref="ChatOptions"/> instance. To override the caller-supplied options
/// with a new instance, the callback may simply return that new instance, for example <c>_ => new ChatOptions() { MaxTokens = 1000 }</c>. To provide
/// a new instance only if the caller-supplied instance is <see langword="null"/>, the callback may conditionally return a new instance, for example
/// <c>options => options ?? new ChatOptions() { MaxTokens = 1000 }</c>. Any changes to the caller-provided options instance will persist on the
/// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance
/// and mutating the clone, for example:
/// <c>
/// options =>
/// {
/// var newOptions = options?.Clone() ?? new();
/// newOptions.MaxTokens = 1000;
/// return newOptions;
/// }
/// </c>
/// </para>
/// <para>
/// The callback may return <see langword="null"/>, in which case a <see langword="null"/> options will be passed to the next client in the pipeline.
/// </para>
/// This can be used to set default options. The <paramref name="configure"/> delegate is passed either a new instance of
/// <see cref="ChatOptions"/> if the caller didn't supply a <see cref="ChatOptions"/> instance, or a clone (via <see cref="ChatOptions.Clone"/>
/// of the caller-supplied instance if one was supplied.
/// </remarks>
public static ChatClientBuilder UseChatOptions(
this ChatClientBuilder builder, Func<ChatOptions?, ChatOptions?> configureOptions)
/// <returns>The <paramref name="builder"/>.</returns>
public static ChatClientBuilder ConfigureOptions(
this ChatClientBuilder builder, Action<ChatOptions> configure)
{
_ = Throw.IfNull(builder);
_ = Throw.IfNull(configureOptions);
_ = Throw.IfNull(configure);
return builder.Use(innerClient => new ConfigureOptionsChatClient(innerClient, configureOptions));
return builder.Use(innerClient => new ConfigureOptionsChatClient(innerClient, configure));
}
}

Просмотреть файл

@ -8,8 +8,12 @@ using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
#pragma warning disable CA2213 // Disposable fields should be disposed
namespace Microsoft.Extensions.AI;
/// <summary>
@ -25,7 +29,7 @@ namespace Microsoft.Extensions.AI;
/// <para>
/// The provided implementation of <see cref="IChatClient"/> is thread-safe for concurrent use so long as the
/// <see cref="AIFunction"/> instances employed as part of the supplied <see cref="ChatOptions"/> are also safe.
/// The <see cref="ConcurrentInvocation"/> property may be used to control whether multiple function invocation
/// The <see cref="ConcurrentInvocation"/> property can be used to control whether multiple function invocation
/// requests as part of the same request are invocable concurrently, but even with that set to <see langword="false"/>
/// (the default), multiple concurrent requests to this same instance and using the same tools could result in those
/// tools being used concurrently (one per request). For example, a function that accesses the HttpContext of a specific
@ -34,8 +38,15 @@ namespace Microsoft.Extensions.AI;
/// invocation requests to that same function.
/// </para>
/// </remarks>
public class FunctionInvokingChatClient : DelegatingChatClient
public partial class FunctionInvokingChatClient : DelegatingChatClient
{
/// <summary>The logger to use for logging information about function invocation.</summary>
private readonly ILogger _logger;
/// <summary>The <see cref="ActivitySource"/> to use for telemetry.</summary>
/// <remarks>This component does not own the instance and should not dispose it.</remarks>
private readonly ActivitySource? _activitySource;
/// <summary>Maximum number of roundtrips allowed to the inner client.</summary>
private int? _maximumIterationsPerRequest;
@ -43,31 +54,28 @@ public class FunctionInvokingChatClient : DelegatingChatClient
/// Initializes a new instance of the <see cref="FunctionInvokingChatClient"/> class.
/// </summary>
/// <param name="innerClient">The underlying <see cref="IChatClient"/>, or the next instance in a chain of clients.</param>
public FunctionInvokingChatClient(IChatClient innerClient)
/// <param name="logger">An <see cref="ILogger"/> to use for logging information about function invocation.</param>
public FunctionInvokingChatClient(IChatClient innerClient, ILogger? logger = null)
: base(innerClient)
{
_logger = logger ?? NullLogger.Instance;
_activitySource = innerClient.GetService<ActivitySource>();
}
/// <summary>
/// Gets or sets a value indicating whether to handle exceptions that occur during function calls.
/// </summary>
/// <remarks>
/// <para>
/// If the value is <see langword="false"/>, then if a function call fails with an exception, the
/// <value>
/// <see langword="false"/> if the
/// underlying <see cref="IChatClient"/> will be instructed to give a response without invoking
/// any further functions.
/// </para>
/// <para>
/// If the value is <see langword="true"/>, the underlying <see cref="IChatClient"/> will be allowed
/// any further functions if a function call fails with an exception.
/// <see langword="true"/> if the underlying <see cref="IChatClient"/> is allowed
/// to continue attempting function calls until <see cref="MaximumIterationsPerRequest"/> is reached.
/// </para>
/// <para>
/// Changing the value of this property while the client is in use may result in inconsistencies
/// as to whether errors are retried during an in-flight request.
/// </para>
/// <para>
/// The default value is <see langword="false"/>.
/// </para>
/// </value>
/// <remarks>
/// Changing the value of this property while the client is in use might result in inconsistencies
/// as to whether errors are retried during an in-flight request.
/// </remarks>
public bool RetryOnError { get; set; }
@ -75,23 +83,27 @@ public class FunctionInvokingChatClient : DelegatingChatClient
/// Gets or sets a value indicating whether detailed exception information should be included
/// in the chat history when calling the underlying <see cref="IChatClient"/>.
/// </summary>
/// <value>
/// <see langword="true"/> if the full exception message is added to the chat history
/// when calling the underlying <see cref="IChatClient"/>.
/// <see langword="false"/> if a generic error message is included in the chat history.
/// The default value is <see langword="false"/>.
/// </value>
/// <remarks>
/// <para>
/// The default value is <see langword="false"/>, meaning that only a generic error message will
/// be included in the chat history. This prevents the underlying language model from disclosing
/// raw exception details to the end user, since it does not receive that information. Even in this
/// Setting the value to <see langword="false"/> prevents the underlying language model from disclosing
/// raw exception details to the end user, since it doesn't receive that information. Even in this
/// case, the raw <see cref="Exception"/> object is available to application code by inspecting
/// the <see cref="FunctionResultContent.Exception"/> property.
/// </para>
/// <para>
/// If set to <see langword="true"/>, the full exception message will be added to the chat history
/// when calling the underlying <see cref="IChatClient"/>. This can help it to bypass problems on
/// its own, for example by retrying the function call with different arguments. However it may
/// result in disclosing the raw exception information to external users, which may be a security
/// Setting the value to <see langword="true"/> can help the underlying <see cref="IChatClient"/> bypass problems on
/// its own, for example by retrying the function call with different arguments. However it might
/// result in disclosing the raw exception information to external users, which can be a security
/// concern depending on the application scenario.
/// </para>
/// <para>
/// Changing the value of this property while the client is in use may result in inconsistencies
/// Changing the value of this property while the client is in use might result in inconsistencies
/// as to whether detailed errors are provided during an in-flight request.
/// </para>
/// </remarks>
@ -100,21 +112,27 @@ public class FunctionInvokingChatClient : DelegatingChatClient
/// <summary>
/// Gets or sets a value indicating whether to allow concurrent invocation of functions.
/// </summary>
/// <remarks>
/// <para>
/// An individual response from the inner client may contain multiple function call requests.
/// By default, such function calls are processed serially. Set <see cref="ConcurrentInvocation"/> to
/// <see langword="true"/> to enable concurrent invocation such that multiple function calls may execute in parallel.
/// </para>
/// <para>
/// <value>
/// <see langword="true"/> if multiple function calls can execute in parallel.
/// <see langword="false"/> if function calls are processed serially.
/// The default value is <see langword="false"/>.
/// </para>
/// </value>
/// <remarks>
/// An individual response from the inner client might contain multiple function call requests.
/// By default, such function calls are processed serially. Set <see cref="ConcurrentInvocation"/> to
/// <see langword="true"/> to enable concurrent invocation such that multiple function calls can execute in parallel.
/// </remarks>
public bool ConcurrentInvocation { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to keep intermediate messages in the chat history.
/// </summary>
/// <value>
/// <see langword="true"/> if intermediate messages persist in the <see cref="IList{ChatMessage}"/> list provided
/// to <see cref="CompleteAsync"/> and <see cref="CompleteStreamingAsync"/> by the caller.
/// <see langword="false"/> if intermediate messages are removed prior to completing the operation.
/// The default value is <see langword="true"/>.
/// </value>
/// <remarks>
/// <para>
/// When the inner <see cref="IChatClient"/> returns <see cref="FunctionCallContent"/> to the
@ -122,13 +140,12 @@ public class FunctionInvokingChatClient : DelegatingChatClient
/// those messages to the list of messages, along with <see cref="FunctionResultContent"/> instances
/// it creates with the results of invoking the requested functions. The resulting augmented
/// list of messages is then passed to the inner client in order to send the results back.
/// By default, <see cref="KeepFunctionCallingMessages"/> is <see langword="true"/>, and those
/// messages will persist in the <see cref="IList{ChatMessage}"/> list provided to <see cref="CompleteAsync"/>
/// By default, those messages persist in the <see cref="IList{ChatMessage}"/> list provided to <see cref="CompleteAsync"/>
/// and <see cref="CompleteStreamingAsync"/> by the caller. Set <see cref="KeepFunctionCallingMessages"/>
/// to <see langword="false"/> to remove those messages prior to completing the operation.
/// </para>
/// <para>
/// Changing the value of this property while the client is in use may result in inconsistencies
/// Changing the value of this property while the client is in use might result in inconsistencies
/// as to whether function calling messages are kept during an in-flight request.
/// </para>
/// </remarks>
@ -137,22 +154,23 @@ public class FunctionInvokingChatClient : DelegatingChatClient
/// <summary>
/// Gets or sets the maximum number of iterations per request.
/// </summary>
/// <value>
/// The maximum number of iterations per request.
/// The default value is <see langword="null"/>.
/// </value>
/// <remarks>
/// <para>
/// Each request to this <see cref="FunctionInvokingChatClient"/> may end up making
/// Each request to this <see cref="FunctionInvokingChatClient"/> might end up making
/// multiple requests to the inner client. Each time the inner client responds with
/// a function call request, this client may perform that invocation and send the results
/// a function call request, this client might perform that invocation and send the results
/// back to the inner client in a new request. This property limits the number of times
/// such a roundtrip is performed. If null, there is no limit applied. If set, the value
/// must be at least one, as it includes the initial request.
/// </para>
/// <para>
/// Changing the value of this property while the client is in use may result in inconsistencies
/// Changing the value of this property while the client is in use might result in inconsistencies
/// as to how many iterations are allowed for an in-flight request.
/// </para>
/// <para>
/// The default value is <see langword="null"/>.
/// </para>
/// </remarks>
public int? MaximumIterationsPerRequest
{
@ -561,14 +579,96 @@ public class FunctionInvokingChatClient : DelegatingChatClient
/// The function invocation context detailing the function to be invoked and its arguments along with additional request information.
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The result of the function invocation. This may be null if the function invocation returned null.</returns>
protected virtual Task<object?> InvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken)
/// <returns>The result of the function invocation, or <see langword="null"/> if the function invocation returned <see langword="null"/>.</returns>
protected virtual async Task<object?> InvokeFunctionAsync(FunctionInvocationContext context, CancellationToken cancellationToken)
{
_ = Throw.IfNull(context);
return context.Function.InvokeAsync(context.CallContent.Arguments, cancellationToken);
using Activity? activity = _activitySource?.StartActivity(context.Function.Metadata.Name);
long startingTimestamp = 0;
if (_logger.IsEnabled(LogLevel.Debug))
{
startingTimestamp = Stopwatch.GetTimestamp();
if (_logger.IsEnabled(LogLevel.Trace))
{
LogInvokingSensitive(context.Function.Metadata.Name, LoggingHelpers.AsJson(context.CallContent.Arguments, context.Function.Metadata.JsonSerializerOptions));
}
else
{
LogInvoking(context.Function.Metadata.Name);
}
}
object? result = null;
try
{
result = await context.Function.InvokeAsync(context.CallContent.Arguments, cancellationToken).ConfigureAwait(false);
}
catch (Exception e)
{
if (activity is not null)
{
_ = activity.SetTag("error.type", e.GetType().FullName)
.SetStatus(ActivityStatusCode.Error, e.Message);
}
if (e is OperationCanceledException)
{
LogInvocationCanceled(context.Function.Metadata.Name);
}
else
{
LogInvocationFailed(context.Function.Metadata.Name, e);
}
throw;
}
finally
{
if (_logger.IsEnabled(LogLevel.Debug))
{
TimeSpan elapsed = GetElapsedTime(startingTimestamp);
if (result is not null && _logger.IsEnabled(LogLevel.Trace))
{
LogInvocationCompletedSensitive(context.Function.Metadata.Name, elapsed, LoggingHelpers.AsJson(result, context.Function.Metadata.JsonSerializerOptions));
}
else
{
LogInvocationCompleted(context.Function.Metadata.Name, elapsed);
}
}
}
return result;
}
private static TimeSpan GetElapsedTime(long startingTimestamp) =>
#if NET
Stopwatch.GetElapsedTime(startingTimestamp);
#else
new((long)((Stopwatch.GetTimestamp() - startingTimestamp) * ((double)TimeSpan.TicksPerSecond / Stopwatch.Frequency)));
#endif
[LoggerMessage(LogLevel.Debug, "Invoking {MethodName}.", SkipEnabledCheck = true)]
private partial void LogInvoking(string methodName);
[LoggerMessage(LogLevel.Trace, "Invoking {MethodName}({Arguments}).", SkipEnabledCheck = true)]
private partial void LogInvokingSensitive(string methodName, string arguments);
[LoggerMessage(LogLevel.Debug, "{MethodName} invocation completed. Duration: {Duration}", SkipEnabledCheck = true)]
private partial void LogInvocationCompleted(string methodName, TimeSpan duration);
[LoggerMessage(LogLevel.Trace, "{MethodName} invocation completed. Duration: {Duration}. Result: {Result}", SkipEnabledCheck = true)]
private partial void LogInvocationCompletedSensitive(string methodName, TimeSpan duration, string result);
[LoggerMessage(LogLevel.Debug, "{MethodName} invocation canceled.")]
private partial void LogInvocationCanceled(string methodName);
[LoggerMessage(LogLevel.Error, "{MethodName} invocation failed.")]
private partial void LogInvocationFailed(string methodName, Exception error);
/// <summary>Provides context for a function invocation.</summary>
public sealed class FunctionInvocationContext
{
@ -611,15 +711,15 @@ public class FunctionInvokingChatClient : DelegatingChatClient
/// <summary>Gets or sets the total number of function call requests within the iteration.</summary>
/// <remarks>
/// The response from the underlying client may include multiple function call requests.
/// The response from the underlying client might include multiple function call requests.
/// This count indicates how many there were.
/// </remarks>
public int FunctionCount { get; set; }
/// <summary>Gets or sets a value indicating whether to terminate the request.</summary>
/// <remarks>
/// In response to a function call request, the function may be invoked, its result added to the chat contents,
/// and a new request issued to the wrapped client. If this property is set to true, that subsequent request
/// In response to a function call request, the function might be invoked, its result added to the chat contents,
/// and a new request issued to the wrapped client. If this property is set to <see langword="true"/>, that subsequent request
/// will not be issued and instead the loop immediately terminated rather than continuing until there are no
/// more function call requests in responses.
/// </remarks>

Просмотреть файл

@ -2,6 +2,8 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
@ -16,15 +18,21 @@ public static class FunctionInvokingChatClientBuilderExtensions
/// </summary>
/// <remarks>This works by adding an instance of <see cref="FunctionInvokingChatClient"/> with default options.</remarks>
/// <param name="builder">The <see cref="ChatClientBuilder"/> being used to build the chat pipeline.</param>
/// <param name="loggerFactory">An optional <see cref="ILoggerFactory"/> to use to create a logger for logging function invocations.</param>
/// <param name="configure">An optional callback that can be used to configure the <see cref="FunctionInvokingChatClient"/> instance.</param>
/// <returns>The supplied <paramref name="builder"/>.</returns>
public static ChatClientBuilder UseFunctionInvocation(this ChatClientBuilder builder, Action<FunctionInvokingChatClient>? configure = null)
public static ChatClientBuilder UseFunctionInvocation(
this ChatClientBuilder builder,
ILoggerFactory? loggerFactory = null,
Action<FunctionInvokingChatClient>? configure = null)
{
_ = Throw.IfNull(builder);
return builder.Use(innerClient =>
return builder.Use((services, innerClient) =>
{
var chatClient = new FunctionInvokingChatClient(innerClient);
loggerFactory ??= services.GetService<ILoggerFactory>();
var chatClient = new FunctionInvokingChatClient(innerClient, loggerFactory?.CreateLogger(typeof(FunctionInvokingChatClient)));
configure?.Invoke(chatClient);
return chatClient;
});

Просмотреть файл

@ -168,7 +168,7 @@ public partial class LoggingChatClient : DelegatingChatClient
}
}
private string AsJson<T>(T value) => JsonSerializer.Serialize(value, _jsonSerializerOptions.GetTypeInfo(typeof(T)));
private string AsJson<T>(T value) => LoggingHelpers.AsJson(value, _jsonSerializerOptions);
[LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")]
private partial void LogInvoked(string methodName);

Просмотреть файл

@ -17,11 +17,13 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
#pragma warning disable S3358 // Ternary operators should not be nested
namespace Microsoft.Extensions.AI;
/// <summary>A delegating chat client that implements the OpenTelemetry Semantic Conventions for Generative AI systems.</summary>
/// <summary>Represents a delegating chat client that implements the OpenTelemetry Semantic Conventions for Generative AI systems.</summary>
/// <remarks>
/// The draft specification this follows is available at https://opentelemetry.io/docs/specs/semconv/gen-ai/.
/// The draft specification this follows is available at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change.
/// </remarks>
public sealed partial class OpenTelemetryChatClient : DelegatingChatClient
@ -100,12 +102,22 @@ public sealed partial class OpenTelemetryChatClient : DelegatingChatClient
/// <summary>
/// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry.
/// </summary>
/// <value>
/// <see langword="true"/> if potentially sensitive information should be included in telemetry;
/// <see langword="false"/> if telemetry shouldn't include raw inputs and outputs.
/// The default value is <see langword="false"/>.
/// </value>
/// <remarks>
/// The value is <see langword="false"/> by default, meaning that telemetry will include metadata such as token counts but not raw inputs
/// and outputs such as message content, function call arguments, and function call results.
/// By default, telemetry includes metadata, such as token counts, but not raw inputs
/// and outputs, such as message content, function call arguments, and function call results.
/// </remarks>
public bool EnableSensitiveData { get; set; }
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType == typeof(ActivitySource) ? _activitySource :
base.GetService(serviceType, serviceKey);
/// <inheritdoc/>
public override async Task<ChatCompletion> CompleteAsync(IList<ChatMessage> chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
@ -189,62 +201,12 @@ public sealed partial class OpenTelemetryChatClient : DelegatingChatClient
}
finally
{
TraceCompletion(activity, requestModelId, ComposeStreamingUpdatesIntoChatCompletion(trackedUpdates), error, stopwatch);
TraceCompletion(activity, requestModelId, trackedUpdates.ToChatCompletion(), error, stopwatch);
await responseEnumerator.DisposeAsync();
}
}
/// <summary>Creates a <see cref="ChatCompletion"/> from a collection of <see cref="StreamingChatCompletionUpdate"/> instances.</summary>
/// <remarks>
/// This only propagates information that's later used by the telemetry. If additional information from the <see cref="ChatCompletion"/>
/// is needed, this implementation should be updated to include it.
/// </remarks>
private static ChatCompletion ComposeStreamingUpdatesIntoChatCompletion(
List<StreamingChatCompletionUpdate> updates)
{
// Group updates by choice index.
Dictionary<int, List<StreamingChatCompletionUpdate>> choices = [];
foreach (var update in updates)
{
if (!choices.TryGetValue(update.ChoiceIndex, out var choiceContents))
{
choices[update.ChoiceIndex] = choiceContents = [];
}
choiceContents.Add(update);
}
// Add a ChatMessage for each choice.
string? id = null;
ChatFinishReason? finishReason = null;
string? modelId = null;
List<ChatMessage> messages = new(choices.Count);
foreach (var choice in choices.OrderBy(c => c.Key))
{
ChatRole? role = null;
List<AIContent> items = [];
foreach (var update in choice.Value)
{
id ??= update.CompletionId;
finishReason ??= update.FinishReason;
role ??= update.Role;
items.AddRange(update.Contents);
modelId ??= update.ModelId;
}
messages.Add(new ChatMessage(role ?? ChatRole.Assistant, items));
}
return new(messages)
{
CompletionId = id,
FinishReason = finishReason,
ModelId = modelId,
Usage = updates.SelectMany(c => c.Contents).OfType<UsageContent>().LastOrDefault()?.Details,
};
}
/// <summary>Creates an activity for a chat completion request, or returns null if not enabled.</summary>
private Activity? CreateAndConfigureActivity(ChatOptions? options)
{
@ -254,7 +216,7 @@ public sealed partial class OpenTelemetryChatClient : DelegatingChatClient
string? modelId = options?.ModelId ?? _modelId;
activity = _activitySource.StartActivity(
$"{OpenTelemetryConsts.GenAI.Chat} {modelId}",
string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.Chat : $"{OpenTelemetryConsts.GenAI.Chat} {modelId}",
ActivityKind.Client);
if (activity is not null)

Просмотреть файл

@ -15,7 +15,7 @@ public static class OpenTelemetryChatClientBuilderExtensions
/// Adds OpenTelemetry support to the chat client pipeline, following the OpenTelemetry Semantic Conventions for Generative AI systems.
/// </summary>
/// <remarks>
/// The draft specification this follows is available at https://opentelemetry.io/docs/specs/semconv/gen-ai/.
/// The draft specification this follows is available at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change.
/// </remarks>
/// <param name="builder">The <see cref="ChatClientBuilder"/>.</param>

Просмотреть файл

@ -11,7 +11,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
/// <summary>A delegating embedding generator that caches the results of embedding generation calls.</summary>
/// <summary>Represents a delegating embedding generator that caches the results of embedding generation calls.</summary>
/// <typeparam name="TInput">The type from which embeddings will be generated.</typeparam>
/// <typeparam name="TEmbedding">The type of embeddings to generate.</typeparam>
public abstract class CachingEmbeddingGenerator<TInput, TEmbedding> : DelegatingEmbeddingGenerator<TInput, TEmbedding>

Просмотреть файл

@ -3,65 +3,41 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
#pragma warning disable SA1629 // Documentation text should end with a period
namespace Microsoft.Extensions.AI;
/// <summary>A delegating embedding generator that updates or replaces the <see cref="EmbeddingGenerationOptions"/> used by the remainder of the pipeline.</summary>
/// <summary>A delegating embedding generator that configures a <see cref="EmbeddingGenerationOptions"/> instance used by the remainder of the pipeline.</summary>
/// <typeparam name="TInput">Specifies the type of the input passed to the generator.</typeparam>
/// <typeparam name="TEmbedding">Specifies the type of the embedding instance produced by the generator.</typeparam>
/// <remarks>
/// <para>
/// The configuration callback is invoked with the caller-supplied <see cref="EmbeddingGenerationOptions"/> instance. To override the caller-supplied options
/// with a new instance, the callback may simply return that new instance, for example <c>_ => new EmbeddingGenerationOptions() { Dimensions = 100 }</c>. To provide
/// a new instance only if the caller-supplied instance is <see langword="null"/>, the callback may conditionally return a new instance, for example
/// <c>options => options ?? new EmbeddingGenerationOptions() { Dimensions = 100 }</c>. Any changes to the caller-provided options instance will persist on the
/// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance
/// and mutating the clone, for example:
/// <c>
/// options =>
/// {
/// var newOptions = options?.Clone() ?? new();
/// newOptions.Dimensions = 100;
/// return newOptions;
/// }
/// </c>
/// </para>
/// <para>
/// The callback may return <see langword="null"/>, in which case a <see langword="null"/> options will be passed to the next generator in the pipeline.
/// </para>
/// <para>
/// The provided implementation of <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/> is thread-safe for concurrent use so long as the employed configuration
/// callback is also thread-safe for concurrent requests. If callers employ a shared options instance, care should be taken in the
/// configuration callback, as multiple calls to it may end up running in parallel with the same options instance.
/// </para>
/// </remarks>
public sealed class ConfigureOptionsEmbeddingGenerator<TInput, TEmbedding> : DelegatingEmbeddingGenerator<TInput, TEmbedding>
where TEmbedding : Embedding
{
/// <summary>The callback delegate used to configure options.</summary>
private readonly Func<EmbeddingGenerationOptions?, EmbeddingGenerationOptions?> _configureOptions;
private readonly Action<EmbeddingGenerationOptions> _configureOptions;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigureOptionsEmbeddingGenerator{TInput, TEmbedding}"/> class with the
/// specified <paramref name="configureOptions"/> callback.
/// specified <paramref name="configure"/> callback.
/// </summary>
/// <param name="innerGenerator">The inner generator.</param>
/// <param name="configureOptions">
/// The delegate to invoke to configure the <see cref="EmbeddingGenerationOptions"/> instance. It is passed the caller-supplied
/// <see cref="EmbeddingGenerationOptions"/> instance and should return the configured <see cref="EmbeddingGenerationOptions"/> instance to use.
/// <param name="configure">
/// The delegate to invoke to configure the <see cref="EmbeddingGenerationOptions"/> instance. It is passed a clone of the caller-supplied
/// <see cref="EmbeddingGenerationOptions"/> instance (or a newly constructed instance if the caller-supplied instance is <see langword="null"/>).
/// </param>
/// <remarks>
/// The <paramref name="configure"/> delegate is passed either a new instance of <see cref="EmbeddingGenerationOptions"/> if
/// the caller didn't supply a <see cref="EmbeddingGenerationOptions"/> instance, or a clone (via <see cref="EmbeddingGenerationOptions.Clone"/> of the caller-supplied
/// instance if one was supplied.
/// </remarks>
public ConfigureOptionsEmbeddingGenerator(
IEmbeddingGenerator<TInput, TEmbedding> innerGenerator,
Func<EmbeddingGenerationOptions?, EmbeddingGenerationOptions?> configureOptions)
Action<EmbeddingGenerationOptions> configure)
: base(innerGenerator)
{
_configureOptions = Throw.IfNull(configureOptions);
_configureOptions = Throw.IfNull(configure);
}
/// <inheritdoc/>
@ -70,6 +46,16 @@ public sealed class ConfigureOptionsEmbeddingGenerator<TInput, TEmbedding> : Del
EmbeddingGenerationOptions? options = null,
CancellationToken cancellationToken = default)
{
return await base.GenerateAsync(values, _configureOptions(options), cancellationToken).ConfigureAwait(false);
return await base.GenerateAsync(values, Configure(options), cancellationToken).ConfigureAwait(false);
}
/// <summary>Creates and configures the <see cref="EmbeddingGenerationOptions"/> to pass along to the inner client.</summary>
private EmbeddingGenerationOptions Configure(EmbeddingGenerationOptions? options)
{
options = options?.Clone() ?? new();
_configureOptions(options);
return options;
}
}

Просмотреть файл

@ -12,45 +12,30 @@ namespace Microsoft.Extensions.AI;
public static class ConfigureOptionsEmbeddingGeneratorBuilderExtensions
{
/// <summary>
/// Adds a callback that updates or replaces <see cref="EmbeddingGenerationOptions"/>. This can be used to set default options.
/// Adds a callback that configures a <see cref="EmbeddingGenerationOptions"/> to be passed to the next client in the pipeline.
/// </summary>
/// <typeparam name="TInput">Specifies the type of the input passed to the generator.</typeparam>
/// <typeparam name="TEmbedding">Specifies the type of the embedding instance produced by the generator.</typeparam>
/// <param name="builder">The <see cref="EmbeddingGeneratorBuilder{TInput, TEmbedding}"/>.</param>
/// <param name="configureOptions">
/// The delegate to invoke to configure the <see cref="EmbeddingGenerationOptions"/> instance. It is passed the caller-supplied
/// <see cref="EmbeddingGenerationOptions"/> instance and should return the configured <see cref="EmbeddingGenerationOptions"/> instance to use.
/// <param name="configure">
/// The delegate to invoke to configure the <see cref="EmbeddingGenerationOptions"/> instance. It is passed a clone of the caller-supplied
/// <see cref="EmbeddingGenerationOptions"/> instance (or a new constructed instance if the caller-supplied instance is <see langword="null"/>).
/// </param>
/// <returns>The <paramref name="builder"/>.</returns>
/// <remarks>
/// <para>
/// The configuration callback is invoked with the caller-supplied <see cref="EmbeddingGenerationOptions"/> instance. To override the caller-supplied options
/// with a new instance, the callback may simply return that new instance, for example <c>_ => new EmbeddingGenerationOptions() { Dimensions = 100 }</c>. To provide
/// a new instance only if the caller-supplied instance is <see langword="null"/>, the callback may conditionally return a new instance, for example
/// <c>options => options ?? new EmbeddingGenerationOptions() { Dimensions = 100 }</c>. Any changes to the caller-provided options instance will persist on the
/// original instance, so the callback must take care to only do so when such mutations are acceptable, such as by cloning the original instance
/// and mutating the clone, for example:
/// <c>
/// options =>
/// {
/// var newOptions = options?.Clone() ?? new();
/// newOptions.Dimensions = 100;
/// return newOptions;
/// }
/// </c>
/// </para>
/// <para>
/// The callback may return <see langword="null"/>, in which case a <see langword="null"/> options will be passed to the next generator in the pipeline.
/// </para>
/// This can be used to set default options. The <paramref name="configure"/> delegate is passed either a new instance of
/// <see cref="EmbeddingGenerationOptions"/> if the caller didn't supply a <see cref="EmbeddingGenerationOptions"/> instance, or
/// a clone (via <see cref="EmbeddingGenerationOptions.Clone"/>
/// of the caller-supplied instance if one was supplied.
/// </remarks>
public static EmbeddingGeneratorBuilder<TInput, TEmbedding> UseEmbeddingGenerationOptions<TInput, TEmbedding>(
/// <returns>The <paramref name="builder"/>.</returns>
public static EmbeddingGeneratorBuilder<TInput, TEmbedding> ConfigureOptions<TInput, TEmbedding>(
this EmbeddingGeneratorBuilder<TInput, TEmbedding> builder,
Func<EmbeddingGenerationOptions?, EmbeddingGenerationOptions?> configureOptions)
Action<EmbeddingGenerationOptions> configure)
where TEmbedding : Embedding
{
_ = Throw.IfNull(builder);
_ = Throw.IfNull(configureOptions);
_ = Throw.IfNull(configure);
return builder.Use(innerGenerator => new ConfigureOptionsEmbeddingGenerator<TInput, TEmbedding>(innerGenerator, configureOptions));
return builder.Use(innerGenerator => new ConfigureOptionsEmbeddingGenerator<TInput, TEmbedding>(innerGenerator, configure));
}
}

Просмотреть файл

@ -11,7 +11,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
/// <summary>
/// A delegating embedding generator that caches the results of embedding generation calls,
/// Represents a delegating embedding generator that caches the results of embedding generation calls,
/// storing them as JSON in an <see cref="IDistributedCache"/>.
/// </summary>
/// <typeparam name="TInput">The type from which embeddings will be generated.</typeparam>

Просмотреть файл

@ -13,9 +13,9 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
/// <summary>A delegating embedding generator that implements the OpenTelemetry Semantic Conventions for Generative AI systems.</summary>
/// <summary>Represents a delegating embedding generator that implements the OpenTelemetry Semantic Conventions for Generative AI systems.</summary>
/// <remarks>
/// The draft specification this follows is available at https://opentelemetry.io/docs/specs/semconv/gen-ai/.
/// The draft specification this follows is available at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
/// The specification is still experimental and subject to change; as such, the telemetry output by this generator is also subject to change.
/// </remarks>
/// <typeparam name="TInput">The type of input used to produce embeddings.</typeparam>
@ -72,13 +72,19 @@ public sealed class OpenTelemetryEmbeddingGenerator<TInput, TEmbedding> : Delega
advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries });
}
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType == typeof(ActivitySource) ? _activitySource :
base.GetService(serviceType, serviceKey);
/// <inheritdoc/>
public override async Task<GeneratedEmbeddings<TEmbedding>> GenerateAsync(IEnumerable<TInput> values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(values);
using Activity? activity = CreateAndConfigureActivity();
using Activity? activity = CreateAndConfigureActivity(options);
Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null;
string? requestModelId = options?.ModelId ?? _modelId;
GeneratedEmbeddings<TEmbedding>? response = null;
Exception? error = null;
@ -93,7 +99,7 @@ public sealed class OpenTelemetryEmbeddingGenerator<TInput, TEmbedding> : Delega
}
finally
{
TraceCompletion(activity, response, error, stopwatch);
TraceCompletion(activity, requestModelId, response, error, stopwatch);
}
return response;
@ -112,18 +118,20 @@ public sealed class OpenTelemetryEmbeddingGenerator<TInput, TEmbedding> : Delega
}
/// <summary>Creates an activity for an embedding generation request, or returns null if not enabled.</summary>
private Activity? CreateAndConfigureActivity()
private Activity? CreateAndConfigureActivity(EmbeddingGenerationOptions? options)
{
Activity? activity = null;
if (_activitySource.HasListeners())
{
string? modelId = options?.ModelId ?? _modelId;
activity = _activitySource.StartActivity(
$"{OpenTelemetryConsts.GenAI.Embed} {_modelId}",
string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.Embed : $"{OpenTelemetryConsts.GenAI.Embed} {modelId}",
ActivityKind.Client,
default(ActivityContext),
[
new(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embed),
new(OpenTelemetryConsts.GenAI.Request.Model, _modelId),
new(OpenTelemetryConsts.GenAI.Request.Model, modelId),
new(OpenTelemetryConsts.GenAI.SystemName, _modelProvider),
]);
@ -149,6 +157,7 @@ public sealed class OpenTelemetryEmbeddingGenerator<TInput, TEmbedding> : Delega
/// <summary>Adds embedding generation response information to the activity.</summary>
private void TraceCompletion(
Activity? activity,
string? requestModelId,
GeneratedEmbeddings<TEmbedding>? embeddings,
Exception? error,
Stopwatch? stopwatch)
@ -167,7 +176,7 @@ public sealed class OpenTelemetryEmbeddingGenerator<TInput, TEmbedding> : Delega
if (_operationDurationHistogram.Enabled && stopwatch is not null)
{
TagList tags = default;
AddMetricTags(ref tags, responseModelId);
AddMetricTags(ref tags, requestModelId, responseModelId);
if (error is not null)
{
tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().FullName);
@ -180,7 +189,7 @@ public sealed class OpenTelemetryEmbeddingGenerator<TInput, TEmbedding> : Delega
{
TagList tags = default;
tags.Add(OpenTelemetryConsts.GenAI.Token.Type, "input");
AddMetricTags(ref tags, responseModelId);
AddMetricTags(ref tags, requestModelId, responseModelId);
_tokenUsageHistogram.Record(inputTokens.Value);
}
@ -206,13 +215,13 @@ public sealed class OpenTelemetryEmbeddingGenerator<TInput, TEmbedding> : Delega
}
}
private void AddMetricTags(ref TagList tags, string? responseModelId)
private void AddMetricTags(ref TagList tags, string? requestModelId, string? responseModelId)
{
tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embed);
if (_modelId is string requestModel)
if (requestModelId is not null)
{
tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModel);
tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModelId);
}
tags.Add(OpenTelemetryConsts.GenAI.SystemName, _modelProvider);

Просмотреть файл

@ -15,7 +15,7 @@ public static class OpenTelemetryEmbeddingGeneratorBuilderExtensions
/// Adds OpenTelemetry support to the embedding generator pipeline, following the OpenTelemetry Semantic Conventions for Generative AI systems.
/// </summary>
/// <remarks>
/// The draft specification this follows is available at https://opentelemetry.io/docs/specs/semconv/gen-ai/.
/// The draft specification this follows is available at <see href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" />.
/// The specification is still experimental and subject to change; as such, the telemetry output by this generator is also subject to change.
/// </remarks>
/// <typeparam name="TInput">The type of input used to produce embeddings.</typeparam>

Просмотреть файл

@ -8,9 +8,9 @@ namespace Microsoft.Extensions.AI;
/// <summary>Provides additional context to the invocation of an <see cref="AIFunction"/> created by <see cref="AIFunctionFactory"/>.</summary>
/// <remarks>
/// A delegate or <see cref="MethodInfo"/> passed to <see cref="AIFunctionFactory"/> methods may represent a method that has a parameter
/// A delegate or <see cref="MethodInfo"/> passed to <see cref="AIFunctionFactory"/> methods can represent a method that has a parameter
/// of type <see cref="AIFunctionContext"/>. Whereas all other parameters are passed by name from the supplied collection of arguments,
/// a <see cref="AIFunctionContext"/> parameter is passed specially by the <see cref="AIFunction"/> implementation, in order to pass relevant
/// an <see cref="AIFunctionContext"/> parameter is passed specially by the <see cref="AIFunction"/> implementation to pass relevant
/// context into the method's invocation. For example, any <see cref="CancellationToken"/> passed to the <see cref="AIFunction.InvokeAsync"/>
/// method is available from the <see cref="AIFunctionContext.CancellationToken"/> property.
/// </remarks>

Просмотреть файл

@ -18,7 +18,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
/// <summary>Provides factory methods for creating commonly-used implementations of <see cref="AIFunction"/>.</summary>
/// <summary>Provides factory methods for creating commonly used implementations of <see cref="AIFunction"/>.</summary>
public static partial class AIFunctionFactory
{
/// <summary>Holds the default options instance used when creating function.</summary>
@ -189,7 +189,7 @@ public static partial class AIFunctionFactory
bool sawAIContextParameter = false;
for (int i = 0; i < parameters.Length; i++)
{
if (GetParameterMarshaller(options.SerializerOptions, parameters[i], ref sawAIContextParameter, out _parameterMarshallers[i]) is AIFunctionParameterMetadata parameterView)
if (GetParameterMarshaller(options, parameters[i], ref sawAIContextParameter, out _parameterMarshallers[i]) is AIFunctionParameterMetadata parameterView)
{
parameterMetadata?.Add(parameterView);
}
@ -209,7 +209,7 @@ public static partial class AIFunctionFactory
{
ParameterType = returnType,
Description = method.ReturnParameter.GetCustomAttribute<DescriptionAttribute>(inherit: true)?.Description,
Schema = AIJsonUtilities.CreateJsonSchema(returnType, serializerOptions: options.SerializerOptions),
Schema = AIJsonUtilities.CreateJsonSchema(returnType, serializerOptions: options.SerializerOptions, inferenceOptions: options.SchemaCreateOptions),
},
AdditionalProperties = options.AdditionalProperties ?? EmptyReadOnlyDictionary<string, object?>.Instance,
JsonSerializerOptions = options.SerializerOptions,
@ -272,7 +272,7 @@ public static partial class AIFunctionFactory
/// Gets a delegate for handling the marshaling of a parameter.
/// </summary>
private static AIFunctionParameterMetadata? GetParameterMarshaller(
JsonSerializerOptions options,
AIFunctionFactoryCreateOptions options,
ParameterInfo parameter,
ref bool sawAIFunctionContext,
out Func<IReadOnlyDictionary<string, object?>, AIFunctionContext?, object?> marshaller)
@ -302,7 +302,7 @@ public static partial class AIFunctionFactory
// Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found.
Type parameterType = parameter.ParameterType;
JsonTypeInfo typeInfo = options.GetTypeInfo(parameterType);
JsonTypeInfo typeInfo = options.SerializerOptions.GetTypeInfo(parameterType);
// Create a marshaller that simply looks up the parameter by name in the arguments dictionary.
marshaller = (IReadOnlyDictionary<string, object?> arguments, AIFunctionContext? _) =>
@ -325,7 +325,7 @@ public static partial class AIFunctionFactory
#pragma warning disable CA1031 // Do not catch general exception types
try
{
string json = JsonSerializer.Serialize(value, options.GetTypeInfo(value.GetType()));
string json = JsonSerializer.Serialize(value, options.SerializerOptions.GetTypeInfo(value.GetType()));
return JsonSerializer.Deserialize(json, typeInfo);
}
catch
@ -361,7 +361,8 @@ public static partial class AIFunctionFactory
description,
parameter.HasDefaultValue,
parameter.DefaultValue,
options)
options.SerializerOptions,
options.SchemaCreateOptions)
};
}

Просмотреть файл

@ -11,11 +11,12 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Options that can be provided when creating an <see cref="AIFunction"/> from a method.
/// Represents options that can be provided when creating an <see cref="AIFunction"/> from a method.
/// </summary>
public sealed class AIFunctionFactoryCreateOptions
{
private JsonSerializerOptions _options = AIJsonUtilities.DefaultOptions;
private AIJsonSchemaCreateOptions _schemaCreateOptions = AIJsonSchemaCreateOptions.Default;
/// <summary>
/// Initializes a new instance of the <see cref="AIFunctionFactoryCreateOptions"/> class.
@ -31,36 +32,45 @@ public sealed class AIFunctionFactoryCreateOptions
set => _options = Throw.IfNull(value);
}
/// <summary>
/// Gets or sets the <see cref="AIJsonSchemaCreateOptions"/> governing the generation of JSON schemas for the function.
/// </summary>
public AIJsonSchemaCreateOptions SchemaCreateOptions
{
get => _schemaCreateOptions;
set => _schemaCreateOptions = Throw.IfNull(value);
}
/// <summary>Gets or sets the name to use for the function.</summary>
/// <remarks>
/// If <see langword="null"/>, it will default to one derived from the method represented by the passed <see cref="Delegate"/> or <see cref="MethodInfo"/>.
/// </remarks>
/// <value>
/// The name to use for the function. The default value is a name derived from the method represented by the passed <see cref="Delegate"/> or <see cref="MethodInfo"/>.
/// </value>
public string? Name { get; set; }
/// <summary>Gets or sets the description to use for the function.</summary>
/// <remarks>
/// If <see langword="null"/>, it will default to one derived from the passed <see cref="Delegate"/> or <see cref="MethodInfo"/>, if possible
/// (e.g. via a <see cref="DescriptionAttribute"/> on the method).
/// </remarks>
/// <value>
/// The description for the function. The default value is a description derived from the passed <see cref="Delegate"/> or <see cref="MethodInfo"/>, if possible
/// (for example, via a <see cref="DescriptionAttribute"/> on the method).
/// </value>
public string? Description { get; set; }
/// <summary>Gets or sets metadata for the parameters of the function.</summary>
/// <remarks>
/// If <see langword="null"/>, it will default to metadata derived from the passed <see cref="Delegate"/> or <see cref="MethodInfo"/>.
/// </remarks>
/// <value>
/// Metadata for the function's parameters. The default value is metadata derived from the passed <see cref="Delegate"/> or <see cref="MethodInfo"/>.
/// </value>
public IReadOnlyList<AIFunctionParameterMetadata>? Parameters { get; set; }
/// <summary>Gets or sets metadata for function's return parameter.</summary>
/// <remarks>
/// If <see langword="null"/>, it will default to one derived from the passed <see cref="Delegate"/> or <see cref="MethodInfo"/>.
/// </remarks>
/// <value>
/// Metadata for the function's return parameter. The default value is metadata derived from the passed <see cref="Delegate"/> or <see cref="MethodInfo"/>.
/// </value>
public AIFunctionReturnParameterMetadata? ReturnParameter { get; set; }
/// <summary>
/// Gets or sets additional values that will be stored on the resulting <see cref="AIFunctionMetadata.AdditionalProperties" /> property.
/// Gets or sets additional values to store on the resulting <see cref="AIFunctionMetadata.AdditionalProperties" /> property.
/// </summary>
/// <remarks>
/// This can be used to provide arbitrary information about the function.
/// This property can be used to provide arbitrary information about the function.
/// </remarks>
public IReadOnlyDictionary<string, object?>? AdditionalProperties { get; set; }
}

Просмотреть файл

@ -0,0 +1,34 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable CA1031 // Do not catch general exception types
#pragma warning disable S108 // Nested blocks of code should not be left empty
#pragma warning disable S2486 // Generic exceptions should not be ignored
using System.Text.Json;
namespace Microsoft.Extensions.AI;
/// <summary>Provides internal helpers for implementing logging.</summary>
internal static class LoggingHelpers
{
/// <summary>Serializes <paramref name="value"/> as JSON for logging purposes.</summary>
public static string AsJson<T>(T value, JsonSerializerOptions? options)
{
if (options?.TryGetTypeInfo(typeof(T), out var typeInfo) is true ||
AIJsonUtilities.DefaultOptions.TryGetTypeInfo(typeof(T), out typeInfo))
{
try
{
return JsonSerializer.Serialize(value, typeInfo);
}
catch
{
}
}
// If we're unable to get a type info for the value, or if we fail to serialize,
// return an empty JSON object. We do not want lack of type info to disrupt application behavior with exceptions.
return "{}";
}
}

Просмотреть файл

@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
@ -41,7 +42,6 @@ internal sealed class ProcessInfo : IProcessInfo
public ulong GetCurrentProcessMemoryUsage()
{
using Process process = Process.GetCurrentProcess();
return (ulong)process.WorkingSet64;
return (ulong)Environment.WorkingSet;
}
}

Просмотреть файл

@ -109,8 +109,7 @@ internal sealed class WindowsSnapshotProvider : ISnapshotProvider
internal static long GetMemoryUsageInBytes()
{
using var process = Process.GetCurrentProcess();
return process.WorkingSet64;
return Environment.WorkingSet;
}
internal static ulong GetTotalMemoryInBytes()

Просмотреть файл

@ -7,6 +7,7 @@ using System.Collections.Generic;
using Microsoft.Extensions.Compliance.Classification;
using Microsoft.Extensions.Compliance.Redaction;
using Microsoft.Extensions.Http.Diagnostics;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.Http.Diagnostics;
@ -56,16 +57,22 @@ internal sealed class HttpRouteParser : IHttpRouteParser
{
var startIndex = segment.Start + offset;
string parameterValue;
// If we exceed a length of the http path it means that the appropriate http route
// has optional parameters or parameters with default values, and these parameters
// are omitted in the http path. In this case we return a default value of the
// omitted parameter.
string parameterValue = segment.DefaultValue;
bool isRedacted = false;
if (startIndex < httpPathAsSpan.Length)
{
var parameterContent = segment.Content;
var parameterTemplateLength = parameterContent.Length + 2;
var length = httpPathAsSpan.Slice(startIndex).IndexOf(ForwardSlash);
if (length == -1)
if (segment.IsCatchAll || length == -1)
{
length = httpPathAsSpan.Slice(startIndex).Length;
}
@ -75,15 +82,6 @@ internal sealed class HttpRouteParser : IHttpRouteParser
parameterValue = GetRedactedParameterValue(httpPathAsSpan, segment, startIndex, length, redactionMode, parametersToRedact, ref isRedacted);
}
// If we exceed a length of the http path it means that the appropriate http route
// has optional parameters or parameters with default values, and these parameters
// are omitted in the http path. In this case we return a default value of the
// omitted parameter.
else
{
parameterValue = segment.DefaultValue;
}
httpRouteParameters[index++] = new HttpRouteParameter(segment.ParamName, parameterValue, isRedacted);
}
}
@ -157,6 +155,8 @@ internal sealed class HttpRouteParser : IHttpRouteParser
int start = pos++;
int paramNameEnd = PositionNotFound;
int paramNameStart = start + 1;
bool catchAllParamFound = false;
int defaultValueStart = PositionNotFound;
char ch;
@ -187,13 +187,42 @@ internal sealed class HttpRouteParser : IHttpRouteParser
}
}
// The segment has '*' catch all parameter.
// When we meet the character it indicates param start position needs to be adjusted, so that we capture 'param' instead of '*param'
// *param can only appear after opening curly brace and position needs to be adjusted only once
else if (!catchAllParamFound && ch == '*' && pos > 0 && httpRoute[pos - 1] == '{')
{
paramNameStart++;
// Catch all parameters can start with one or two '*' characters.
if (httpRoute[paramNameStart] == '*')
{
paramNameStart++;
}
catchAllParamFound = true;
}
pos++;
}
string content = GetSegmentContent(httpRoute, start + 1, pos);
// Throw an ArgumentException if the segment is a catch-all parameter and not the last segment.
// The current position should be either the end of the route or the second to last position followed by a '/'.
if (catchAllParamFound)
{
bool isLastPosition = pos == httpRoute.Length - 1;
bool isSecondToLastPosition = pos == httpRoute.Length - 2;
if (!(isLastPosition || (isSecondToLastPosition && httpRoute[pos + 1] == '/')))
{
Throw.ArgumentException(nameof(httpRoute), "A catch-all parameter must be the last segment in the route.");
}
}
string content = GetSegmentContent(httpRoute, paramNameStart, pos);
string paramName = paramNameEnd == PositionNotFound
? content
: GetSegmentContent(httpRoute, start + 1, paramNameEnd);
: GetSegmentContent(httpRoute, paramNameStart, paramNameEnd);
string defaultValue = defaultValueStart == PositionNotFound
? string.Empty
: GetSegmentContent(httpRoute, defaultValueStart, pos);
@ -205,7 +234,8 @@ internal sealed class HttpRouteParser : IHttpRouteParser
content: content,
isParam: true,
paramName: paramName,
defaultValue: defaultValue);
defaultValue: defaultValue,
isCatchAll: catchAllParamFound);
}
private static string GetSegmentContent(string httpRoute, int start, int end)

Просмотреть файл

@ -24,9 +24,10 @@ internal readonly struct Segment
/// <param name="isParam">If the segment is a param.</param>
/// <param name="paramName">Name of the parameter.</param>
/// <param name="defaultValue">Default value of the parameter.</param>
/// <param name="isCatchAll">If the segment is a catch-all parameter.</param>
public Segment(
int start, int end, string content, bool isParam,
string paramName = "", string defaultValue = "")
string paramName = "", string defaultValue = "", bool isCatchAll = false)
{
Start = start;
End = end;
@ -34,6 +35,7 @@ internal readonly struct Segment
IsParam = isParam;
ParamName = paramName;
DefaultValue = defaultValue;
IsCatchAll = isCatchAll;
}
/// <summary>
@ -66,6 +68,11 @@ internal readonly struct Segment
/// </summary>
public string DefaultValue { get; } = string.Empty;
/// <summary>
/// Gets a value indicating whether the segment is a catch-all parameter.
/// </summary>
public bool IsCatchAll { get; }
internal static bool IsKnownUnredactableParameter(string parameter) =>
parameter.Equals(ControllerParameter, StringComparison.OrdinalIgnoreCase) ||
parameter.Equals(ActionParameter, StringComparison.OrdinalIgnoreCase);

Просмотреть файл

@ -11,6 +11,12 @@ namespace Microsoft.Extensions.AI;
public class ChatClientExtensionsTests
{
[Fact]
public void GetService_InvalidArgs_Throws()
{
Assert.Throws<ArgumentNullException>("client", () => ChatClientExtensions.GetService<object>(null!));
}
[Fact]
public void CompleteAsync_InvalidArgs_Throws()
{

Просмотреть файл

@ -167,4 +167,131 @@ public class ChatCompletionTests
Assert.IsType<JsonElement>(value);
Assert.Equal("value", ((JsonElement)value!).GetString());
}
[Fact]
public void ToString_OneChoice_OutputsChatMessageToString()
{
ChatCompletion completion = new(
[
new ChatMessage(ChatRole.Assistant, "This is a test." + Environment.NewLine + "It's multiple lines.")
]);
Assert.Equal(completion.Choices[0].Text, completion.ToString());
}
[Fact]
public void ToString_MultipleChoices_OutputsAllChoicesWithPrefix()
{
ChatCompletion completion = new(
[
new ChatMessage(ChatRole.Assistant, "This is a test." + Environment.NewLine + "It's multiple lines."),
new ChatMessage(ChatRole.Assistant, "So is" + Environment.NewLine + " this."),
new ChatMessage(ChatRole.Assistant, "And this."),
]);
Assert.Equal(
"Choice 0:" + Environment.NewLine +
completion.Choices[0] + Environment.NewLine + Environment.NewLine +
"Choice 1:" + Environment.NewLine +
completion.Choices[1] + Environment.NewLine + Environment.NewLine +
"Choice 2:" + Environment.NewLine +
completion.Choices[2],
completion.ToString());
}
[Fact]
public void ToStreamingChatCompletionUpdates_SingleChoice()
{
ChatCompletion completion = new(new ChatMessage(new ChatRole("customRole"), "Text"))
{
CompletionId = "12345",
ModelId = "someModel",
FinishReason = ChatFinishReason.ContentFilter,
CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero),
AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 42 },
};
StreamingChatCompletionUpdate[] updates = completion.ToStreamingChatCompletionUpdates();
Assert.NotNull(updates);
Assert.Equal(2, updates.Length);
StreamingChatCompletionUpdate update0 = updates[0];
Assert.Equal("12345", update0.CompletionId);
Assert.Equal("someModel", update0.ModelId);
Assert.Equal(ChatFinishReason.ContentFilter, update0.FinishReason);
Assert.Equal(new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), update0.CreatedAt);
Assert.Equal("customRole", update0.Role?.Value);
Assert.Equal("Text", update0.Text);
StreamingChatCompletionUpdate update1 = updates[1];
Assert.Equal("value1", update1.AdditionalProperties?["key1"]);
Assert.Equal(42, update1.AdditionalProperties?["key2"]);
}
[Fact]
public void ToStreamingChatCompletionUpdates_MultiChoice()
{
ChatCompletion completion = new(
[
new ChatMessage(ChatRole.Assistant,
[
new TextContent("Hello, "),
new ImageContent("http://localhost/image.png"),
new TextContent("world!"),
])
{
AdditionalProperties = new() { ["choice1Key"] = "choice1Value" },
},
new ChatMessage(ChatRole.System,
[
new FunctionCallContent("call123", "name"),
new FunctionResultContent("call123", "name", 42),
])
{
AdditionalProperties = new() { ["choice2Key"] = "choice2Value" },
},
])
{
CompletionId = "12345",
ModelId = "someModel",
FinishReason = ChatFinishReason.ContentFilter,
CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero),
AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 42 },
Usage = new UsageDetails { TotalTokenCount = 123 },
};
StreamingChatCompletionUpdate[] updates = completion.ToStreamingChatCompletionUpdates();
Assert.NotNull(updates);
Assert.Equal(3, updates.Length);
StreamingChatCompletionUpdate update0 = updates[0];
Assert.Equal("12345", update0.CompletionId);
Assert.Equal("someModel", update0.ModelId);
Assert.Equal(ChatFinishReason.ContentFilter, update0.FinishReason);
Assert.Equal(new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), update0.CreatedAt);
Assert.Equal("assistant", update0.Role?.Value);
Assert.Equal("Hello, ", Assert.IsType<TextContent>(update0.Contents[0]).Text);
Assert.IsType<ImageContent>(update0.Contents[1]);
Assert.Equal("world!", Assert.IsType<TextContent>(update0.Contents[2]).Text);
Assert.Equal("choice1Value", update0.AdditionalProperties?["choice1Key"]);
StreamingChatCompletionUpdate update1 = updates[1];
Assert.Equal("12345", update1.CompletionId);
Assert.Equal("someModel", update1.ModelId);
Assert.Equal(ChatFinishReason.ContentFilter, update1.FinishReason);
Assert.Equal(new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), update1.CreatedAt);
Assert.Equal("system", update1.Role?.Value);
Assert.IsType<FunctionCallContent>(update1.Contents[0]);
Assert.IsType<FunctionResultContent>(update1.Contents[1]);
Assert.Equal("choice2Value", update1.AdditionalProperties?["choice2Key"]);
StreamingChatCompletionUpdate update2 = updates[2];
Assert.Equal("value1", update2.AdditionalProperties?["key1"]);
Assert.Equal(42, update2.AdditionalProperties?["key2"]);
Assert.Equal(123, Assert.IsType<UsageContent>(Assert.Single(update2.Contents)).Details.TotalTokenCount);
}
}

Просмотреть файл

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Xunit;
@ -91,7 +92,7 @@ public class ChatMessageTests
}
Assert.Equal("text-0", message.Text);
Assert.Equal("text-0", message.ToString());
Assert.Equal(string.Concat(Enumerable.Range(0, messageCount).Select(i => $"text-{i}")), message.ToString());
}
Assert.Null(message.AuthorName);
@ -134,13 +135,13 @@ public class ChatMessageTests
TextContent textContent = Assert.IsType<TextContent>(message.Contents[3]);
Assert.Equal("text-1", textContent.Text);
Assert.Equal("text-1", message.Text);
Assert.Equal("text-1", message.ToString());
Assert.Equal("text-1text-2", message.ToString());
message.Text = "text-3";
Assert.Equal("text-3", message.Text);
Assert.Equal("text-3", message.Text);
Assert.Same(textContent, message.Contents[3]);
Assert.Equal("text-3", message.ToString());
Assert.Equal("text-3text-2", message.ToString());
}
[Fact]

Просмотреть файл

@ -96,6 +96,14 @@ public class DelegatingChatClientTests
Assert.False(await enumerator.MoveNextAsync());
}
[Fact]
public void GetServiceThrowsForNullType()
{
using var inner = new TestChatClient();
using var delegating = new NoOpDelegatingChatClient(inner);
Assert.Throws<ArgumentNullException>("serviceType", () => delegating.GetService(null!));
}
[Fact]
public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull()
{

Просмотреть файл

@ -0,0 +1,220 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;
using Xunit;
#pragma warning disable SA1204 // Static elements should appear before instance elements
namespace Microsoft.Extensions.AI;
public class StreamingChatCompletionUpdateExtensionsTests
{
[Fact]
public void InvalidArgs_Throws()
{
Assert.Throws<ArgumentNullException>("updates", () => ((List<StreamingChatCompletionUpdate>)null!).ToChatCompletion());
}
public static IEnumerable<object?[]> ToChatCompletion_SuccessfullyCreatesCompletion_MemberData()
{
foreach (bool useAsync in new[] { false, true })
{
foreach (bool? coalesceContent in new bool?[] { null, false, true })
{
yield return new object?[] { useAsync, coalesceContent };
}
}
}
[Theory]
[MemberData(nameof(ToChatCompletion_SuccessfullyCreatesCompletion_MemberData))]
public async Task ToChatCompletion_SuccessfullyCreatesCompletion(bool useAsync, bool? coalesceContent)
{
StreamingChatCompletionUpdate[] updates =
[
new() { ChoiceIndex = 0, Text = "Hello", CompletionId = "12345", CreatedAt = new DateTimeOffset(1, 2, 3, 4, 5, 6, TimeSpan.Zero), ModelId = "model123" },
new() { ChoiceIndex = 1, Text = "Hey", CompletionId = "12345", CreatedAt = new DateTimeOffset(1, 2, 3, 4, 5, 6, TimeSpan.Zero), ModelId = "model124" },
new() { ChoiceIndex = 0, Text = ", ", AuthorName = "Someone", Role = ChatRole.User, AdditionalProperties = new() { ["a"] = "b" } },
new() { ChoiceIndex = 1, Text = ", ", AuthorName = "Else", Role = ChatRole.System, AdditionalProperties = new() { ["g"] = "h" } },
new() { ChoiceIndex = 0, Text = "world!", CreatedAt = new DateTimeOffset(2, 2, 3, 4, 5, 6, TimeSpan.Zero), AdditionalProperties = new() { ["c"] = "d" } },
new() { ChoiceIndex = 1, Text = "you!", Role = ChatRole.Tool, CreatedAt = new DateTimeOffset(3, 2, 3, 4, 5, 6, TimeSpan.Zero), AdditionalProperties = new() { ["e"] = "f", ["i"] = 42 } },
new() { ChoiceIndex = 0, Contents = new[] { new UsageContent(new() { InputTokenCount = 1, OutputTokenCount = 2 }) } },
new() { ChoiceIndex = 3, Contents = new[] { new UsageContent(new() { InputTokenCount = 4, OutputTokenCount = 5 }) } },
];
ChatCompletion completion = (coalesceContent is bool, useAsync) switch
{
(false, false) => updates.ToChatCompletion(),
(false, true) => await YieldAsync(updates).ToChatCompletionAsync(),
(true, false) => updates.ToChatCompletion(coalesceContent.GetValueOrDefault()),
(true, true) => await YieldAsync(updates).ToChatCompletionAsync(coalesceContent.GetValueOrDefault()),
};
Assert.NotNull(completion);
Assert.Equal("12345", completion.CompletionId);
Assert.Equal(new DateTimeOffset(1, 2, 3, 4, 5, 6, TimeSpan.Zero), completion.CreatedAt);
Assert.Equal("model123", completion.ModelId);
Assert.Same(Assert.IsType<UsageContent>(updates[6].Contents[0]).Details, completion.Usage);
Assert.Equal(3, completion.Choices.Count);
ChatMessage message = completion.Choices[0];
Assert.Equal(ChatRole.User, message.Role);
Assert.Equal("Someone", message.AuthorName);
Assert.NotNull(message.AdditionalProperties);
Assert.Equal(2, message.AdditionalProperties.Count);
Assert.Equal("b", message.AdditionalProperties["a"]);
Assert.Equal("d", message.AdditionalProperties["c"]);
message = completion.Choices[1];
Assert.Equal(ChatRole.System, message.Role);
Assert.Equal("Else", message.AuthorName);
Assert.NotNull(message.AdditionalProperties);
Assert.Equal(3, message.AdditionalProperties.Count);
Assert.Equal("h", message.AdditionalProperties["g"]);
Assert.Equal("f", message.AdditionalProperties["e"]);
Assert.Equal(42, message.AdditionalProperties["i"]);
message = completion.Choices[2];
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Null(message.AuthorName);
Assert.Null(message.AdditionalProperties);
Assert.Same(updates[7].Contents[0], Assert.Single(message.Contents));
if (coalesceContent is null or true)
{
Assert.Equal("Hello, world!", completion.Choices[0].Text);
Assert.Equal("Hey, you!", completion.Choices[1].Text);
Assert.Null(completion.Choices[2].Text);
}
else
{
Assert.Equal("Hello", completion.Choices[0].Contents[0].ToString());
Assert.Equal(", ", completion.Choices[0].Contents[1].ToString());
Assert.Equal("world!", completion.Choices[0].Contents[2].ToString());
Assert.Equal("Hey", completion.Choices[1].Contents[0].ToString());
Assert.Equal(", ", completion.Choices[1].Contents[1].ToString());
Assert.Equal("you!", completion.Choices[1].Contents[2].ToString());
Assert.Null(completion.Choices[2].Text);
}
}
public static IEnumerable<object[]> ToChatCompletion_Coalescing_VariousSequenceAndGapLengths_MemberData()
{
foreach (bool useAsync in new[] { false, true })
{
for (int numSequences = 1; numSequences <= 3; numSequences++)
{
for (int sequenceLength = 1; sequenceLength <= 3; sequenceLength++)
{
for (int gapLength = 1; gapLength <= 3; gapLength++)
{
foreach (bool gapBeginningEnd in new[] { false, true })
{
yield return new object[] { useAsync, numSequences, sequenceLength, gapLength, false };
}
}
}
}
}
}
[Theory]
[MemberData(nameof(ToChatCompletion_Coalescing_VariousSequenceAndGapLengths_MemberData))]
public async Task ToChatCompletion_Coalescing_VariousSequenceAndGapLengths(bool useAsync, int numSequences, int sequenceLength, int gapLength, bool gapBeginningEnd)
{
List<StreamingChatCompletionUpdate> updates = [];
List<string> expected = [];
if (gapBeginningEnd)
{
AddGap();
}
for (int sequenceNum = 0; sequenceNum < numSequences; sequenceNum++)
{
StringBuilder sb = new();
for (int i = 0; i < sequenceLength; i++)
{
string text = $"{(char)('A' + sequenceNum)}{i}";
updates.Add(new() { Text = text });
sb.Append(text);
}
expected.Add(sb.ToString());
if (sequenceNum < numSequences - 1)
{
AddGap();
}
}
if (gapBeginningEnd)
{
AddGap();
}
void AddGap()
{
for (int i = 0; i < gapLength; i++)
{
updates.Add(new() { Contents = [new ImageContent("https://uri")] });
}
}
ChatCompletion completion = useAsync ? await YieldAsync(updates).ToChatCompletionAsync() : updates.ToChatCompletion();
Assert.Single(completion.Choices);
ChatMessage message = completion.Message;
Assert.Equal(expected.Count + (gapLength * ((numSequences - 1) + (gapBeginningEnd ? 2 : 0))), message.Contents.Count);
TextContent[] contents = message.Contents.OfType<TextContent>().ToArray();
Assert.Equal(expected.Count, contents.Length);
for (int i = 0; i < expected.Count; i++)
{
Assert.Equal(expected[i], contents[i].Text);
}
}
[Fact]
public async Task ToChatCompletion_UsageContentExtractedFromContents()
{
StreamingChatCompletionUpdate[] updates =
{
new() { Text = "Hello, " },
new() { Text = "world!" },
new() { Contents = [new UsageContent(new() { TotalTokenCount = 42 })] },
};
ChatCompletion completion = await YieldAsync(updates).ToChatCompletionAsync();
Assert.NotNull(completion);
Assert.NotNull(completion.Usage);
Assert.Equal(42, completion.Usage.TotalTokenCount);
Assert.Equal("Hello, world!", Assert.IsType<TextContent>(Assert.Single(completion.Message.Contents)).Text);
}
private static async IAsyncEnumerable<StreamingChatCompletionUpdate> YieldAsync(IEnumerable<StreamingChatCompletionUpdate> updates)
{
foreach (StreamingChatCompletionUpdate update in updates)
{
await Task.Yield();
yield return update;
}
}
}

Просмотреть файл

@ -103,13 +103,13 @@ public class StreamingChatCompletionUpdateTests
TextContent textContent = Assert.IsType<TextContent>(update.Contents[3]);
Assert.Equal("text-1", textContent.Text);
Assert.Equal("text-1", update.Text);
Assert.Equal("text-1", update.ToString());
Assert.Equal("text-1text-2", update.ToString());
update.Text = "text-3";
Assert.Equal("text-3", update.Text);
Assert.Equal("text-3", update.Text);
Assert.Same(textContent, update.Contents[3]);
Assert.Equal("text-3", update.ToString());
Assert.Equal("text-3text-2", update.ToString());
}
[Fact]

Просмотреть файл

@ -57,6 +57,14 @@ public class DelegatingEmbeddingGeneratorTests
Assert.Same(expectedEmbedding, await resultTask);
}
[Fact]
public void GetServiceThrowsForNullType()
{
using var inner = new TestEmbeddingGenerator();
using var delegating = new NoOpDelegatingEmbeddingGenerator(inner);
Assert.Throws<ArgumentNullException>("serviceType", () => delegating.GetService(null!));
}
[Fact]
public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull()
{

Просмотреть файл

@ -10,6 +10,13 @@ namespace Microsoft.Extensions.AI;
public class EmbeddingGeneratorExtensionsTests
{
[Fact]
public void GetService_InvalidArgs_Throws()
{
Assert.Throws<ArgumentNullException>("generator", () => EmbeddingGeneratorExtensions.GetService<object>(null!));
Assert.Throws<ArgumentNullException>("generator", () => EmbeddingGeneratorExtensions.GetService<string, Embedding<double>, object>(null!));
}
[Fact]
public async Task GenerateAsync_InvalidArgs_ThrowsAsync()
{

Просмотреть файл

@ -18,7 +18,7 @@ public sealed class TestChatClient : IChatClient
public Func<IList<ChatMessage>, ChatOptions?, CancellationToken, IAsyncEnumerable<StreamingChatCompletionUpdate>>? CompleteStreamingAsyncCallback { get; set; }
public Func<Type, object?, object?>? GetServiceCallback { get; set; }
public Func<Type, object?, object?> GetServiceCallback { get; set; } = (_, _) => null;
public Task<ChatCompletion> CompleteAsync(IList<ChatMessage> chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> CompleteAsyncCallback!.Invoke(chatMessages, options, cancellationToken);
@ -26,9 +26,8 @@ public sealed class TestChatClient : IChatClient
public IAsyncEnumerable<StreamingChatCompletionUpdate> CompleteStreamingAsync(IList<ChatMessage> chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> CompleteStreamingAsyncCallback!.Invoke(chatMessages, options, cancellationToken);
public TService? GetService<TService>(object? key = null)
where TService : class
=> (TService?)GetServiceCallback!(typeof(TService), key);
public object? GetService(Type serviceType, object? serviceKey = null)
=> GetServiceCallback(serviceType, serviceKey);
void IDisposable.Dispose()
{

Просмотреть файл

@ -14,14 +14,13 @@ public sealed class TestEmbeddingGenerator : IEmbeddingGenerator<string, Embeddi
public Func<IEnumerable<string>, EmbeddingGenerationOptions?, CancellationToken, Task<GeneratedEmbeddings<Embedding<float>>>>? GenerateAsyncCallback { get; set; }
public Func<Type, object?, object?>? GetServiceCallback { get; set; }
public Func<Type, object?, object?> GetServiceCallback { get; set; } = (_, _) => null;
public Task<GeneratedEmbeddings<Embedding<float>>> GenerateAsync(IEnumerable<string> values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default)
=> GenerateAsyncCallback!.Invoke(values, options, cancellationToken);
public TService? GetService<TService>(object? key = null)
where TService : class
=> (TService?)GetServiceCallback!(typeof(TService), key);
public object? GetService(Type serviceType, object? serviceKey = null)
=> GetServiceCallback(serviceType, serviceKey);
void IDisposable.Dispose()
{

Просмотреть файл

@ -1,10 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.ComponentModel;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Extensions.AI.JsonSchemaExporter;
using Xunit;
@ -38,9 +41,11 @@ public static class AIJsonUtilitiesTests
public static void AIJsonSchemaCreateOptions_DefaultInstance_ReturnsExpectedValues(bool useSingleton)
{
AIJsonSchemaCreateOptions options = useSingleton ? AIJsonSchemaCreateOptions.Default : new AIJsonSchemaCreateOptions();
Assert.False(options.IncludeTypeInEnumSchemas);
Assert.False(options.DisallowAdditionalProperties);
Assert.True(options.IncludeTypeInEnumSchemas);
Assert.True(options.DisallowAdditionalProperties);
Assert.False(options.IncludeSchemaKeyword);
Assert.True(options.RequireAllProperties);
Assert.True(options.FilterDisallowedKeywords);
}
[Fact]
@ -56,6 +61,7 @@ public static class AIJsonUtilitiesTests
"type": "integer"
},
"EnumValue": {
"type": "string",
"enum": ["A", "B"]
},
"Value": {
@ -63,11 +69,13 @@ public static class AIJsonUtilitiesTests
"default": null
}
},
"required": ["Key", "EnumValue"]
"required": ["Key", "EnumValue", "Value"],
"additionalProperties": false
}
""").RootElement;
JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(MyPoco), serializerOptions: JsonSerializerOptions.Default);
Assert.True(JsonElement.DeepEquals(expected, actual));
}
@ -85,7 +93,6 @@ public static class AIJsonUtilitiesTests
"type": "integer"
},
"EnumValue": {
"type": "string",
"enum": ["A", "B"]
},
"Value": {
@ -94,28 +101,109 @@ public static class AIJsonUtilitiesTests
}
},
"required": ["Key", "EnumValue"],
"additionalProperties": false,
"default": "42"
}
""").RootElement;
AIJsonSchemaCreateOptions inferenceOptions = new AIJsonSchemaCreateOptions
{
IncludeTypeInEnumSchemas = true,
DisallowAdditionalProperties = true,
IncludeSchemaKeyword = true
IncludeTypeInEnumSchemas = false,
DisallowAdditionalProperties = false,
IncludeSchemaKeyword = true,
RequireAllProperties = false,
};
JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(MyPoco),
JsonElement actual = AIJsonUtilities.CreateJsonSchema(
typeof(MyPoco),
description: "alternative description",
hasDefaultValue: true,
defaultValue: 42,
JsonSerializerOptions.Default,
inferenceOptions);
serializerOptions: JsonSerializerOptions.Default,
inferenceOptions: inferenceOptions);
Assert.True(JsonElement.DeepEquals(expected, actual));
}
[Fact]
public static void CreateJsonSchema_FiltersDisallowedKeywords()
{
JsonElement expected = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"Date": {
"type": "string"
},
"TimeSpan": {
"$comment": "Represents a System.TimeSpan value.",
"type": "string"
},
"Char" : {
"type": "string"
}
},
"required": ["Date","TimeSpan","Char"],
"additionalProperties": false
}
""").RootElement;
JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(PocoWithTypesWithOpenAIUnsupportedKeywords), serializerOptions: JsonSerializerOptions.Default);
Assert.True(JsonElement.DeepEquals(expected, actual));
}
[Fact]
public static void CreateJsonSchema_FilterDisallowedKeywords_Disabled()
{
JsonElement expected = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"Date": {
"type": "string",
"format": "date-time"
},
"TimeSpan": {
"$comment": "Represents a System.TimeSpan value.",
"type": "string",
"pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$"
},
"Char" : {
"type": "string",
"minLength": 1,
"maxLength": 1
}
},
"required": ["Date","TimeSpan","Char"],
"additionalProperties": false
}
""").RootElement;
AIJsonSchemaCreateOptions inferenceOptions = new()
{
FilterDisallowedKeywords = false
};
JsonElement actual = AIJsonUtilities.CreateJsonSchema(
typeof(PocoWithTypesWithOpenAIUnsupportedKeywords),
serializerOptions: JsonSerializerOptions.Default,
inferenceOptions: inferenceOptions);
Assert.True(JsonElement.DeepEquals(expected, actual));
}
public class PocoWithTypesWithOpenAIUnsupportedKeywords
{
// Uses the unsupported "format" keyword
public DateTimeOffset Date { get; init; }
// Uses the unsupported "pattern" keyword
public TimeSpan TimeSpan { get; init; }
// Uses the unsupported "minLength" and "maxLength" keywords
public char Char { get; init; }
}
[Fact]
public static void ResolveParameterJsonSchema_ReturnsExpectedValue()
{
@ -178,7 +266,12 @@ public static class AIJsonUtilitiesTests
? new(opts) { TypeInfoResolver = TestTypes.TestTypesContext.Default }
: TestTypes.TestTypesContext.Default.Options;
JsonElement schema = AIJsonUtilities.CreateJsonSchema(testData.Type, serializerOptions: options);
JsonTypeInfo typeInfo = options.GetTypeInfo(testData.Type);
AIJsonSchemaCreateOptions? createOptions = typeInfo.Properties.Any(prop => prop.IsExtensionData)
? new() { DisallowAdditionalProperties = false } // Do not append additionalProperties: false to the schema if the type has extension data.
: null;
JsonElement schema = AIJsonUtilities.CreateJsonSchema(testData.Type, serializerOptions: options, inferenceOptions: createOptions);
JsonNode? schemaAsNode = JsonSerializer.SerializeToNode(schema, options);
Assert.NotNull(schemaAsNode);

Просмотреть файл

@ -378,7 +378,7 @@ public abstract class ChatClientIntegrationTests : IDisposable
// First call executes the function and calls the LLM
using var chatClient = new ChatClientBuilder()
.UseChatOptions(_ => new() { Tools = [getTemperature] })
.ConfigureOptions(options => options.Tools = [getTemperature])
.UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions())))
.UseFunctionInvocation()
.UseCallCounting()
@ -416,7 +416,7 @@ public abstract class ChatClientIntegrationTests : IDisposable
// First call executes the function and calls the LLM
using var chatClient = new ChatClientBuilder()
.UseChatOptions(_ => new() { Tools = [getTemperature] })
.ConfigureOptions(options => options.Tools = [getTemperature])
.UseFunctionInvocation()
.UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions())))
.UseCallCounting()
@ -455,7 +455,7 @@ public abstract class ChatClientIntegrationTests : IDisposable
// First call executes the function and calls the LLM
using var chatClient = new ChatClientBuilder()
.UseChatOptions(_ => new() { Tools = [getTemperature] })
.ConfigureOptions(options => options.Tools = [getTemperature])
.UseFunctionInvocation()
.UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions())))
.UseCallCounting()

Просмотреть файл

@ -29,10 +29,9 @@ internal sealed class QuantizationEmbeddingGenerator :
void IDisposable.Dispose() => _floatService.Dispose();
public TService? GetService<TService>(object? key = null)
where TService : class =>
key is null && this is TService ? (TService?)(object)this :
_floatService.GetService<TService>(key);
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceKey is null && serviceType.IsInstanceOfType(this) ? this :
_floatService.GetService(serviceType, serviceKey);
async Task<GeneratedEmbeddings<BinaryEmbedding>> IEmbeddingGenerator<string, BinaryEmbedding>.GenerateAsync(
IEnumerable<string> values, EmbeddingGenerationOptions? options, CancellationToken cancellationToken)

Просмотреть файл

@ -15,24 +15,24 @@ public class ConfigureOptionsChatClientTests
[Fact]
public void ConfigureOptionsChatClient_InvalidArgs_Throws()
{
Assert.Throws<ArgumentNullException>("innerClient", () => new ConfigureOptionsChatClient(null!, _ => new ChatOptions()));
Assert.Throws<ArgumentNullException>("configureOptions", () => new ConfigureOptionsChatClient(new TestChatClient(), null!));
Assert.Throws<ArgumentNullException>("innerClient", () => new ConfigureOptionsChatClient(null!, _ => { }));
Assert.Throws<ArgumentNullException>("configure", () => new ConfigureOptionsChatClient(new TestChatClient(), null!));
}
[Fact]
public void UseChatOptions_InvalidArgs_Throws()
public void ConfigureOptions_InvalidArgs_Throws()
{
var builder = new ChatClientBuilder();
Assert.Throws<ArgumentNullException>("configureOptions", () => builder.UseChatOptions(null!));
Assert.Throws<ArgumentNullException>("configure", () => builder.ConfigureOptions(null!));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullReturned)
public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullProvidedOptions)
{
ChatOptions providedOptions = new();
ChatOptions? returnedOptions = nullReturned ? null : new();
ChatOptions? providedOptions = nullProvidedOptions ? null : new() { ModelId = "test" };
ChatOptions? returnedOptions = null;
ChatCompletion expectedCompletion = new(Array.Empty<ChatMessage>());
var expectedUpdates = Enumerable.Range(0, 3).Select(i => new StreamingChatCompletionUpdate()).ToArray();
using CancellationTokenSource cts = new();
@ -55,10 +55,19 @@ public class ConfigureOptionsChatClientTests
};
using var client = new ChatClientBuilder()
.UseChatOptions(options =>
.ConfigureOptions(options =>
{
Assert.Same(providedOptions, options);
return returnedOptions;
Assert.NotSame(providedOptions, options);
if (nullProvidedOptions)
{
Assert.Null(options.ModelId);
}
else
{
Assert.Equal(providedOptions!.ModelId, options.ModelId);
}
returnedOptions = options;
})
.Use(innerClient);

Просмотреть файл

@ -214,19 +214,18 @@ public class DistributedCachingChatClientTest
// Verify that all the expected properties will round-trip through the cache,
// even if this involves serialization
List<StreamingChatCompletionUpdate> expectedCompletion =
List<StreamingChatCompletionUpdate> actualCompletion =
[
new()
{
Role = new ChatRole("fakeRole1"),
ChoiceIndex = 3,
ChoiceIndex = 1,
AdditionalProperties = new() { ["a"] = "b" },
Contents = [new TextContent("Chunk1")]
},
new()
{
Role = new ChatRole("fakeRole2"),
Text = "Chunk2",
Contents =
[
new FunctionCallContent("someCallId", "someFn", new Dictionary<string, object?> { ["arg1"] = "value1" }),
@ -235,13 +234,33 @@ public class DistributedCachingChatClientTest
}
];
List<StreamingChatCompletionUpdate> expectedCachedCompletion =
[
new()
{
Role = new ChatRole("fakeRole2"),
Contents = [new FunctionCallContent("someCallId", "someFn", new Dictionary<string, object?> { ["arg1"] = "value1" })],
},
new()
{
Role = new ChatRole("fakeRole1"),
ChoiceIndex = 1,
AdditionalProperties = new() { ["a"] = "b" },
Contents = [new TextContent("Chunk1")]
},
new()
{
Contents = [new UsageContent(new() { InputTokenCount = 123, OutputTokenCount = 456, TotalTokenCount = 99999 })],
},
];
var innerCallCount = 0;
using var testClient = new TestChatClient
{
CompleteStreamingAsyncCallback = delegate
{
innerCallCount++;
return ToAsyncEnumerableAsync(expectedCompletion);
return ToAsyncEnumerableAsync(actualCompletion);
}
};
using var outer = new DistributedCachingChatClient(testClient, _storage)
@ -251,7 +270,7 @@ public class DistributedCachingChatClientTest
// Make the initial request and do a quick sanity check
var result1 = outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some input")]);
await AssertCompletionsEqualAsync(expectedCompletion, result1);
await AssertCompletionsEqualAsync(actualCompletion, result1);
Assert.Equal(1, innerCallCount);
// Act
@ -259,7 +278,7 @@ public class DistributedCachingChatClientTest
// Assert
Assert.Equal(1, innerCallCount);
await AssertCompletionsEqualAsync(expectedCompletion, result2);
await AssertCompletionsEqualAsync(expectedCachedCompletion, result2);
// Act/Assert 2: Cache misses do not return cached results
await ToListAsync(outer.CompleteStreamingAsync([new ChatMessage(ChatRole.User, "some modified input")]));
@ -306,10 +325,11 @@ public class DistributedCachingChatClientTest
// Assert
if (coalesce is null or true)
{
Assert.Collection(await ToListAsync(result2),
c => Assert.Equal("This becomes one chunk", c.Text),
c => Assert.IsType<FunctionCallContent>(Assert.Single(c.Contents)),
c => Assert.Equal("... and this becomes another one.", c.Text));
StreamingChatCompletionUpdate update = Assert.Single(await ToListAsync(result2));
Assert.Collection(update.Contents,
c => Assert.Equal("This becomes one chunk", Assert.IsType<TextContent>(c).Text),
c => Assert.IsType<FunctionCallContent>(c),
c => Assert.Equal("... and this becomes another one.", Assert.IsType<TextContent>(c).Text));
}
else
{
@ -396,7 +416,6 @@ public class DistributedCachingChatClientTest
List<StreamingChatCompletionUpdate> expectedCompletion =
[
new() { Role = ChatRole.Assistant, Text = "Chunk 1" },
new() { Role = ChatRole.System, Text = "Chunk 2" },
];
using var testClient = new TestChatClient
{

Просмотреть файл

@ -3,15 +3,26 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using OpenTelemetry.Trace;
using Xunit;
namespace Microsoft.Extensions.AI;
public class FunctionInvokingChatClientTests
{
[Fact]
public void InvalidArgs_Throws()
{
Assert.Throws<ArgumentNullException>("innerClient", () => new FunctionInvokingChatClient(null!));
Assert.Throws<ArgumentNullException>("builder", () => ((ChatClientBuilder)null!).UseFunctionInvocation());
}
[Fact]
public void Ctor_HasExpectedDefaults()
{
@ -294,6 +305,89 @@ public class FunctionInvokingChatClientTests
Assert.Single(chat); // It didn't add anything to the chat history
}
[Theory]
[InlineData(LogLevel.Trace)]
[InlineData(LogLevel.Debug)]
[InlineData(LogLevel.Information)]
public async Task FunctionInvocationsLogged(LogLevel level)
{
using CapturingLoggerProvider clp = new();
ServiceCollection c = new();
c.AddLogging(b => b.AddProvider(clp).SetMinimumLevel(level));
var services = c.BuildServiceProvider();
var options = new ChatOptions
{
Tools = [AIFunctionFactory.Create(() => "Result 1", "Func1")]
};
await InvokeAndAssertAsync(options, [
new ChatMessage(ChatRole.User, "hello"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1", new Dictionary<string, object?> { ["arg1"] = "value1" })]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", "Func1", result: "Result 1")]),
new ChatMessage(ChatRole.Assistant, "world"),
], configurePipeline: b => b.Use(c => new FunctionInvokingChatClient(c, services.GetRequiredService<ILogger<FunctionInvokingChatClient>>())));
if (level is LogLevel.Trace)
{
Assert.Collection(clp.Logger.Entries,
entry => Assert.True(entry.Message.Contains("Invoking Func1({") && entry.Message.Contains("\"arg1\": \"value1\"")),
entry => Assert.True(entry.Message.Contains("Func1 invocation completed. Duration:") && entry.Message.Contains("Result: \"Result 1\"")));
}
else if (level is LogLevel.Debug)
{
Assert.Collection(clp.Logger.Entries,
entry => Assert.True(entry.Message.Contains("Invoking Func1") && !entry.Message.Contains("arg1")),
entry => Assert.True(entry.Message.Contains("Func1 invocation completed. Duration:") && !entry.Message.Contains("Result")));
}
else
{
Assert.Empty(clp.Logger.Entries);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task FunctionInvocationTrackedWithActivity(bool enableTelemetry)
{
string sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using TracerProvider? tracerProvider = enableTelemetry ?
OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build() :
null;
var options = new ChatOptions
{
Tools = [AIFunctionFactory.Create(() => "Result 1", "Func1")]
};
await InvokeAndAssertAsync(options, [
new ChatMessage(ChatRole.User, "hello"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1", new Dictionary<string, object?> { ["arg1"] = "value1" })]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", "Func1", result: "Result 1")]),
new ChatMessage(ChatRole.Assistant, "world"),
], configurePipeline: b => b.Use(c =>
new FunctionInvokingChatClient(
new OpenTelemetryChatClient(c, sourceName: sourceName))));
if (enableTelemetry)
{
Assert.Collection(activities,
activity => Assert.Equal("chat", activity.DisplayName),
activity => Assert.Equal("Func1", activity.DisplayName),
activity => Assert.Equal("chat", activity.DisplayName));
}
else
{
Assert.Empty(activities);
}
}
private static async Task<List<ChatMessage>> InvokeAndAssertAsync(
ChatOptions options,
List<ChatMessage> plan,

Просмотреть файл

@ -13,24 +13,24 @@ public class ConfigureOptionsEmbeddingGeneratorTests
[Fact]
public void ConfigureOptionsEmbeddingGenerator_InvalidArgs_Throws()
{
Assert.Throws<ArgumentNullException>("innerGenerator", () => new ConfigureOptionsEmbeddingGenerator<string, Embedding<float>>(null!, _ => new EmbeddingGenerationOptions()));
Assert.Throws<ArgumentNullException>("configureOptions", () => new ConfigureOptionsEmbeddingGenerator<string, Embedding<float>>(new TestEmbeddingGenerator(), null!));
Assert.Throws<ArgumentNullException>("innerGenerator", () => new ConfigureOptionsEmbeddingGenerator<string, Embedding<float>>(null!, _ => { }));
Assert.Throws<ArgumentNullException>("configure", () => new ConfigureOptionsEmbeddingGenerator<string, Embedding<float>>(new TestEmbeddingGenerator(), null!));
}
[Fact]
public void UseEmbeddingGenerationOptions_InvalidArgs_Throws()
public void ConfigureOptions_InvalidArgs_Throws()
{
var builder = new EmbeddingGeneratorBuilder<string, Embedding<float>>();
Assert.Throws<ArgumentNullException>("configureOptions", () => builder.UseEmbeddingGenerationOptions(null!));
Assert.Throws<ArgumentNullException>("configure", () => builder.ConfigureOptions(null!));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullReturned)
public async Task ConfigureOptions_ReturnedInstancePassedToNextClient(bool nullProvidedOptions)
{
EmbeddingGenerationOptions providedOptions = new();
EmbeddingGenerationOptions? returnedOptions = nullReturned ? null : new();
EmbeddingGenerationOptions? providedOptions = nullProvidedOptions ? null : new() { ModelId = "test" };
EmbeddingGenerationOptions? returnedOptions = null;
GeneratedEmbeddings<Embedding<float>> expectedEmbeddings = [];
using CancellationTokenSource cts = new();
@ -45,10 +45,19 @@ public class ConfigureOptionsEmbeddingGeneratorTests
};
using var generator = new EmbeddingGeneratorBuilder<string, Embedding<float>>()
.UseEmbeddingGenerationOptions(options =>
.ConfigureOptions(options =>
{
Assert.Same(providedOptions, options);
return returnedOptions;
Assert.NotSame(providedOptions, options);
if (nullProvidedOptions)
{
Assert.Null(options.ModelId);
}
else
{
Assert.Equal(providedOptions!.ModelId, options.ModelId);
}
returnedOptions = options;
})
.Use(innerGenerator);

Просмотреть файл

@ -182,4 +182,17 @@ public class AIFunctionFactoryTest
Assert.Equal(returnParameterMetadata, func.Metadata.ReturnParameter);
Assert.Equal(metadata, func.Metadata.AdditionalProperties);
}
[Fact]
public void AIFunctionFactoryCreateOptions_SchemaOptions_HasExpectedDefaults()
{
var options = new AIFunctionFactoryCreateOptions();
var schemaOptions = options.SchemaCreateOptions;
Assert.NotNull(schemaOptions);
Assert.True(schemaOptions.IncludeTypeInEnumSchemas);
Assert.True(schemaOptions.FilterDisallowedKeywords);
Assert.True(schemaOptions.RequireAllProperties);
Assert.True(schemaOptions.DisallowAdditionalProperties);
}
}

Просмотреть файл

@ -0,0 +1,23 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows.Interop;
using Microsoft.TestUtilities;
using Xunit;
namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows.Test;
/// <summary>
/// Process Info Interop Tests.
/// </summary>
/// <remarks>These tests are added for coverage reasons, but the code doesn't have
/// the necessary environment predictability to really test it.</remarks>
public sealed class ProcessInfoTests
{
[ConditionalFact]
public void GetCurrentProcessMemoryUsage()
{
var workingSet64 = new ProcessInfo().GetCurrentProcessMemoryUsage();
Assert.True(workingSet64 > 0);
}
}

Просмотреть файл

@ -377,6 +377,97 @@ public class HttpParserTests
ValidateRouteParameter(httpRouteParameters[1], "chatId", "", false);
}
[Theory]
[CombinatorialData]
public void TryExtractParameters_WhenRouteHasCatchAllParameter_ReturnsCorrectParameters(
bool routeHasMessageSegment,
bool roundTripSyntax,
HttpRouteParameterRedactionMode redactionMode)
{
bool isRedacted = redactionMode != HttpRouteParameterRedactionMode.None;
string redactedPrefix = isRedacted ? "Redacted:" : string.Empty;
HttpRouteParser httpParser = CreateHttpRouteParser();
Dictionary<string, DataClassification> parametersToRedact = new()
{
{ "routeId", FakeTaxonomy.PrivateData },
{ "chatId", FakeTaxonomy.PrivateData },
{ "catchAll", FakeTaxonomy.PrivateData },
};
string httpPath = "api/routes/routeId123/chats/chatId123/messages/1/2/3/";
var paramName = "*catchAll";
if (roundTripSyntax)
{
paramName = "**catchAll";
}
var expectedValue = "messages/1/2/3/";
var segment = string.Empty;
if (routeHasMessageSegment)
{
segment = "/messages";
expectedValue = "1/2/3/";
}
string httpRoute = $"api/routes/{{routeId}}/chats/{{chatId}}{segment}/{{{paramName}}}/";
var routeSegments = httpParser.ParseRoute(httpRoute);
var httpRouteParameters = new HttpRouteParameter[3];
var success = httpParser.TryExtractParameters(httpPath, routeSegments, redactionMode, parametersToRedact, ref httpRouteParameters);
Assert.True(success);
ValidateRouteParameter(httpRouteParameters[0], "routeId", $"{redactedPrefix}routeId123", isRedacted);
ValidateRouteParameter(httpRouteParameters[1], "chatId", $"{redactedPrefix}chatId123", isRedacted);
ValidateRouteParameter(httpRouteParameters[2], "catchAll", $"{redactedPrefix}{expectedValue}", isRedacted);
}
[Theory]
[CombinatorialData]
public void TryExtractParameters_WhenRouteHasCatchAllParameter_Optional_ReturnsCorrectParameters(
bool routeHasDefaultValue,
bool useRoundTripSyntax,
HttpRouteParameterRedactionMode redactionMode)
{
bool isRedacted = redactionMode != HttpRouteParameterRedactionMode.None;
string redactedPrefix = isRedacted ? "Redacted:" : string.Empty;
HttpRouteParser httpParser = CreateHttpRouteParser();
Dictionary<string, DataClassification> parametersToRedact = new()
{
{ "routeId", FakeTaxonomy.PrivateData },
{ "chatId", FakeTaxonomy.PrivateData },
{ "catchAll", FakeTaxonomy.PrivateData },
};
var httpPath = "api/routes/routeId123/chats/chatId123";
var paramName = "*catchAll";
if (useRoundTripSyntax)
{
paramName = "**catchAll";
}
var expectedValue = string.Empty;
if (routeHasDefaultValue)
{
expectedValue = nameof(routeHasDefaultValue);
paramName += $"={expectedValue}";
}
var httpRoute = $"api/routes/{{routeId}}/chats/{{chatId}}/{{{paramName}}}";
var routeSegments = httpParser.ParseRoute(httpRoute);
var httpRouteParameters = new HttpRouteParameter[3];
var success = httpParser.TryExtractParameters(httpPath, routeSegments, redactionMode, parametersToRedact, ref httpRouteParameters);
Assert.True(success);
ValidateRouteParameter(httpRouteParameters[0], "routeId", $"{redactedPrefix}routeId123", isRedacted);
ValidateRouteParameter(httpRouteParameters[1], "chatId", $"{redactedPrefix}chatId123", isRedacted);
ValidateRouteParameter(httpRouteParameters[2], "catchAll", expectedValue, false);
}
[Fact]
public void ParseRoute_WithRouteParameter_ReturnsRouteSegments()
{
@ -389,10 +480,10 @@ public class HttpParserTests
Assert.Equal(4, routeSegments.Segments.Length);
Assert.Equal("api/routes/{routeId}/chats/{chatId}", routeSegments.RouteTemplate);
ValidateRouteSegment(routeSegments.Segments[0], "api/routes/", false, "", "", 0, 11);
ValidateRouteSegment(routeSegments.Segments[1], "routeId", true, "routeId", "", 11, 20);
ValidateRouteSegment(routeSegments.Segments[2], "/chats/", false, "", "", 20, 27);
ValidateRouteSegment(routeSegments.Segments[3], "chatId", true, "chatId", "", 27, 35);
ValidateRouteSegment(routeSegments.Segments[0], ("api/routes/", false, "", "", 0, 11, false));
ValidateRouteSegment(routeSegments.Segments[1], ("routeId", true, "routeId", "", 11, 20, false));
ValidateRouteSegment(routeSegments.Segments[2], ("/chats/", false, "", "", 20, 27, false));
ValidateRouteSegment(routeSegments.Segments[3], ("chatId", true, "chatId", "", 27, 35, false));
// An http route has parameters and ends with text.
httpRoute = "/api/routes/{routeId}/chats/{chatId}/messages";
@ -401,11 +492,11 @@ public class HttpParserTests
Assert.Equal(5, routeSegments.Segments.Length);
Assert.Equal("api/routes/{routeId}/chats/{chatId}/messages", routeSegments.RouteTemplate);
ValidateRouteSegment(routeSegments.Segments[0], "api/routes/", false, "", "", 0, 11);
ValidateRouteSegment(routeSegments.Segments[1], "routeId", true, "routeId", "", 11, 20);
ValidateRouteSegment(routeSegments.Segments[2], "/chats/", false, "", "", 20, 27);
ValidateRouteSegment(routeSegments.Segments[3], "chatId", true, "chatId", "", 27, 35);
ValidateRouteSegment(routeSegments.Segments[4], "/messages", false, "", "", 35, 44);
ValidateRouteSegment(routeSegments.Segments[0], ("api/routes/", false, "", "", 0, 11, false));
ValidateRouteSegment(routeSegments.Segments[1], ("routeId", true, "routeId", "", 11, 20, false));
ValidateRouteSegment(routeSegments.Segments[2], ("/chats/", false, "", "", 20, 27, false));
ValidateRouteSegment(routeSegments.Segments[3], ("chatId", true, "chatId", "", 27, 35, false));
ValidateRouteSegment(routeSegments.Segments[4], ("/messages", false, "", "", 35, 44, false));
}
[Fact]
@ -419,11 +510,11 @@ public class HttpParserTests
Assert.Equal(5, routeSegments.Segments.Length);
Assert.Equal("api/routes/{routeId}/chats/{chatId}/messages", routeSegments.RouteTemplate);
ValidateRouteSegment(routeSegments.Segments[0], "api/routes/", false, "", "", 0, 11);
ValidateRouteSegment(routeSegments.Segments[1], "routeId", true, "routeId", "", 11, 20);
ValidateRouteSegment(routeSegments.Segments[2], "/chats/", false, "", "", 20, 27);
ValidateRouteSegment(routeSegments.Segments[3], "chatId", true, "chatId", "", 27, 35);
ValidateRouteSegment(routeSegments.Segments[4], "/messages", false, "", "", 35, 44);
ValidateRouteSegment(routeSegments.Segments[0], ("api/routes/", false, "", "", 0, 11, false));
ValidateRouteSegment(routeSegments.Segments[1], ("routeId", true, "routeId", "", 11, 20, false));
ValidateRouteSegment(routeSegments.Segments[2], ("/chats/", false, "", "", 20, 27, false));
ValidateRouteSegment(routeSegments.Segments[3], ("chatId", true, "chatId", "", 27, 35, false));
ValidateRouteSegment(routeSegments.Segments[4], ("/messages", false, "", "", 35, 44, false));
// Route doesn't start with forward slash, the final result should begin with forward slash.
httpRoute = "api/routes/{routeId}/chats/{chatId}/messages?from=7";
@ -432,11 +523,11 @@ public class HttpParserTests
Assert.Equal(5, routeSegments.Segments.Length);
Assert.Equal("api/routes/{routeId}/chats/{chatId}/messages", routeSegments.RouteTemplate);
ValidateRouteSegment(routeSegments.Segments[0], "api/routes/", false, "", "", 0, 11);
ValidateRouteSegment(routeSegments.Segments[1], "routeId", true, "routeId", "", 11, 20);
ValidateRouteSegment(routeSegments.Segments[2], "/chats/", false, "", "", 20, 27);
ValidateRouteSegment(routeSegments.Segments[3], "chatId", true, "chatId", "", 27, 35);
ValidateRouteSegment(routeSegments.Segments[4], "/messages", false, "", "", 35, 44);
ValidateRouteSegment(routeSegments.Segments[0], ("api/routes/", false, "", "", 0, 11, false));
ValidateRouteSegment(routeSegments.Segments[1], ("routeId", true, "routeId", "", 11, 20, false));
ValidateRouteSegment(routeSegments.Segments[2], ("/chats/", false, "", "", 20, 27, false));
ValidateRouteSegment(routeSegments.Segments[3], ("chatId", true, "chatId", "", 27, 35, false));
ValidateRouteSegment(routeSegments.Segments[4], ("/messages", false, "", "", 35, 44, false));
}
[Fact]
@ -450,14 +541,101 @@ public class HttpParserTests
Assert.Equal(8, routeSegments.Segments.Length);
Assert.Equal(httpRoute, routeSegments.RouteTemplate);
ValidateRouteSegment(routeSegments.Segments[0], "api/", false, "", "", 0, 4);
ValidateRouteSegment(routeSegments.Segments[1], "controller=home", true, "controller", "home", 4, 21);
ValidateRouteSegment(routeSegments.Segments[2], "/", false, "", "", 21, 22);
ValidateRouteSegment(routeSegments.Segments[3], "action=index", true, "action", "index", 22, 36);
ValidateRouteSegment(routeSegments.Segments[4], "/", false, "", "", 36, 37);
ValidateRouteSegment(routeSegments.Segments[5], "routeId:int:min(1)", true, "routeId", "", 37, 57);
ValidateRouteSegment(routeSegments.Segments[6], "/", false, "", "", 57, 58);
ValidateRouteSegment(routeSegments.Segments[7], "chatId?", true, "chatId", "", 58, 67);
ValidateRouteSegment(routeSegments.Segments[0], ("api/", false, "", "", 0, 4, false));
ValidateRouteSegment(routeSegments.Segments[1], ("controller=home", true, "controller", "home", 4, 21, false));
ValidateRouteSegment(routeSegments.Segments[2], ("/", false, "", "", 21, 22, false));
ValidateRouteSegment(routeSegments.Segments[3], ("action=index", true, "action", "index", 22, 36, false));
ValidateRouteSegment(routeSegments.Segments[4], ("/", false, "", "", 36, 37, false));
ValidateRouteSegment(routeSegments.Segments[5], ("routeId:int:min(1)", true, "routeId", "", 37, 57, false));
ValidateRouteSegment(routeSegments.Segments[6], ("/", false, "", "", 57, 58, false));
ValidateRouteSegment(routeSegments.Segments[7], ("chatId?", true, "chatId", "", 58, 67, false));
}
[Theory]
[InlineData("api/{controller=home}/{action=index}/{*url}/{invalid}")]
[InlineData("api/{controller=home}/{action=index}/{**url}/{invalid}")]
public void ParseRoute_WhenRouteHasCatchAllParameter_OutOfOrder(string httpRoute)
{
HttpRouteParser httpParser = CreateHttpRouteParser();
var exception = Assert.Throws<ArgumentException>(() => httpParser.ParseRoute(httpRoute));
Assert.StartsWith("A catch-all parameter must be the last segment in the route.", exception.Message);
}
[Theory]
[InlineData("api/{controller=home}/{action=index}/{*url}")]
[InlineData("api/{controller=home}/{action=index}/{*url}/")]
[InlineData("api/{controller=home}/{action=index}/{**url}")]
[InlineData("api/{controller=home}/{action=index}/{**url}/")]
public void ParseRoute_WhenRouteHasCatchAllParameter_InCorrectPosition(string httpRoute)
{
HttpRouteParser httpParser = CreateHttpRouteParser();
ParsedRouteSegments routeSegments = httpParser.ParseRoute(httpRoute);
Assert.Equal(3, routeSegments.ParameterCount);
Assert.Equal(httpRoute, routeSegments.RouteTemplate);
}
[Theory]
[InlineData("api/{controller=home}/{action=index}/{*url}", 37, 43)]
[InlineData("api/{controller=home}/{action=index}/{**url}", 37, 44)]
public void ParseRoute_WhenRouteHasCatchAllParameter_ReturnsRouteSegments(string httpRoute, int start, int end)
{
HttpRouteParser httpParser = CreateHttpRouteParser();
ParsedRouteSegments routeSegments = httpParser.ParseRoute(httpRoute);
Assert.Equal(6, routeSegments.Segments.Length);
Assert.Equal(httpRoute, routeSegments.RouteTemplate);
ValidateRouteSegment(routeSegments.Segments[0], ("api/", false, "", "", 0, 4, false));
ValidateRouteSegment(routeSegments.Segments[1], ("controller=home", true, "controller", "home", 4, 21, false));
ValidateRouteSegment(routeSegments.Segments[2], ("/", false, "", "", 21, 22, false));
ValidateRouteSegment(routeSegments.Segments[3], ("action=index", true, "action", "index", 22, 36, false));
ValidateRouteSegment(routeSegments.Segments[4], ("/", false, "", "", 36, 37, false));
ValidateRouteSegment(routeSegments.Segments[5], ("url", true, "url", "", start, end, true));
}
[Theory]
[InlineData("api/{controller=home}/{action=index}/{*url:int:min(1)}", 37, 54)]
[InlineData("api/{controller=home}/{action=index}/{**url:int:min(1)}", 37, 55)]
public void ParseRoute_WhenRouteHasCatchAllParameterWithRouteConstraint_ReturnsRouteSegments(string httpRoute, int start, int end)
{
HttpRouteParser httpParser = CreateHttpRouteParser();
ParsedRouteSegments routeSegments = httpParser.ParseRoute(httpRoute);
Assert.Equal(6, routeSegments.Segments.Length);
Assert.Equal(httpRoute, routeSegments.RouteTemplate);
ValidateRouteSegment(routeSegments.Segments[0], ("api/", false, "", "", 0, 4, false));
ValidateRouteSegment(routeSegments.Segments[1], ("controller=home", true, "controller", "home", 4, 21, false));
ValidateRouteSegment(routeSegments.Segments[2], ("/", false, "", "", 21, 22, false));
ValidateRouteSegment(routeSegments.Segments[3], ("action=index", true, "action", "index", 22, 36, false));
ValidateRouteSegment(routeSegments.Segments[4], ("/", false, "", "", 36, 37, false));
ValidateRouteSegment(routeSegments.Segments[5], ("url:int:min(1)", true, "url", "", start, end, true));
}
[Theory]
[InlineData("api/{controller=home}/{action=index}/{*url:regex(^(web|shared*)$)}", 37, 66)]
[InlineData("api/{controller=home}/{action=index}/{**url:regex(^(web|shared*)$)}", 37, 67)]
public void ParseRoute_WhenRouteHasCatchAllParameterWithRouteConstraintContainingRegexWithStar_ReturnsRouteSegments(string httpRoute, int start, int end)
{
HttpRouteParser httpParser = CreateHttpRouteParser();
ParsedRouteSegments routeSegments = httpParser.ParseRoute(httpRoute);
Assert.Equal(6, routeSegments.Segments.Length);
Assert.Equal(httpRoute, routeSegments.RouteTemplate);
ValidateRouteSegment(routeSegments.Segments[0], ("api/", false, "", "", 0, 4, false));
ValidateRouteSegment(routeSegments.Segments[1], ("controller=home", true, "controller", "home", 4, 21, false));
ValidateRouteSegment(routeSegments.Segments[2], ("/", false, "", "", 21, 22, false));
ValidateRouteSegment(routeSegments.Segments[3], ("action=index", true, "action", "index", 22, 36, false));
ValidateRouteSegment(routeSegments.Segments[4], ("/", false, "", "", 36, 37, false));
ValidateRouteSegment(routeSegments.Segments[5], ("url:regex(^(web|shared*)$)", true, "url", "", start, end, true));
}
[Fact]
@ -488,13 +666,16 @@ public class HttpParserTests
}
private static void ValidateRouteSegment(
Segment segment, string content, bool isParam, string paramName, string defaultValue, int start, int end)
Segment segment, (string content, bool isParam, string paramName, string defaultValue, int start, int end, bool isCatchAll) values)
{
var (content, isParam, paramName, defaultValue, start, end, isCatchAll) = values;
Assert.Equal(content, segment.Content);
Assert.Equal(isParam, segment.IsParam);
Assert.Equal(paramName, segment.ParamName);
Assert.Equal(defaultValue, segment.DefaultValue);
Assert.Equal(start, segment.Start);
Assert.Equal(end, segment.End);
Assert.Equal(isCatchAll, segment.IsCatchAll);
}
}