зеркало из https://github.com/dotnet/extensions.git
Merge branch 'main' into evgenyfedorov2/log_sampling
This commit is contained in:
Коммит
d49c1f1543
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче