diff --git a/.netconfig b/.netconfig index 7397983..07e5173 100644 --- a/.netconfig +++ b/.netconfig @@ -168,4 +168,4 @@ url = https://github.com/andrewlock/NetEscapades.Configuration/blob/master/src/NetEscapades.Configuration.Yaml/YamlConfigurationStreamParser.cs weak sha = a1ec2c6746d96b4f6f140509aa68dcff09271146 - etag = 9e5c6908edc34eb661d647671f79153d8f3a54ebdc848c8765c78d2715f2f657 + etag = 9e5c6908edc34eb661d647671f79153d8f3a54ebdc848c8765c78d2715f2f657 \ No newline at end of file diff --git a/AI.slnx b/AI.slnx index 6f1f494..f5d49a4 100644 --- a/AI.slnx +++ b/AI.slnx @@ -11,6 +11,7 @@ + diff --git a/sample/Aspire/Aspire.csproj b/sample/Aspire/Aspire.csproj index bb2ca40..e19de01 100644 --- a/sample/Aspire/Aspire.csproj +++ b/sample/Aspire/Aspire.csproj @@ -8,7 +8,7 @@ - + diff --git a/sample/Client/Client.csproj b/sample/Client/Client.csproj index c01cbf1..e7d215d 100644 --- a/sample/Client/Client.csproj +++ b/sample/Client/Client.csproj @@ -6,18 +6,18 @@ - - - + + + - - + + - - - - - + + + + + diff --git a/sample/Server/Server.csproj b/sample/Server/Server.csproj index 5183465..d95b24a 100644 --- a/sample/Server/Server.csproj +++ b/sample/Server/Server.csproj @@ -12,12 +12,12 @@ - - - - - - + + + + + + diff --git a/src/Agents/Agents.csproj b/src/Agents/Agents.csproj index 8602f8d..b084dad 100644 --- a/src/Agents/Agents.csproj +++ b/src/Agents/Agents.csproj @@ -17,21 +17,12 @@ $(NoWarn);CS0436;SYSLIB1100;SYSLIB1101;MEAI001 - - - - - - - - - - - - - - + + + + + diff --git a/src/Extensions.Grok/Extensions.Grok.csproj b/src/Extensions.Grok/Extensions.Grok.csproj new file mode 100644 index 0000000..43d64d0 --- /dev/null +++ b/src/Extensions.Grok/Extensions.Grok.csproj @@ -0,0 +1,31 @@ + + + + net8.0;net10.0 + Devlooped.Extensions.AI.Grok + $(AssemblyName) + $(AssemblyName) + Grok implementation for Microsoft.Extensions.AI + + OSMFEULA.txt + true + true + MEAI001;DEAI001;$(NoWarn) + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Extensions.Grok/Extensions/Throw.cs b/src/Extensions.Grok/Extensions/Throw.cs new file mode 100644 index 0000000..eea3e12 --- /dev/null +++ b/src/Extensions.Grok/Extensions/Throw.cs @@ -0,0 +1,992 @@ +// +#region License +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// Adapted from https://github.com/dotnet/extensions/blob/main/src/Shared/Throw/Throw.cs +#endregion + +#nullable enable +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +#pragma warning disable CA1716 +namespace System; +#pragma warning restore CA1716 + +/// +/// Defines static methods used to throw exceptions. +/// +/// +/// The main purpose is to reduce code size, improve performance, and standardize exception +/// messages. +/// +[SuppressMessage("Minor Code Smell", "S4136:Method overloads should be grouped together", Justification = "Doesn't work with the region layout")] +[SuppressMessage("Minor Code Smell", "S2333:Partial is gratuitous in this context", Justification = "Some projects add additional partial parts.")] +[SuppressMessage("Design", "CA1716", Justification = "Not part of an API")] + +#if !SHARED_PROJECT +[ExcludeFromCodeCoverage] +#endif + +static partial class Throw +{ + #region For Object + + /// + /// Throws an if the specified argument is . + /// + /// Argument type to be checked for . + /// Object to be checked for . + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: NotNull] + public static T IfNull([NotNull] T argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument is null) + { + ArgumentNullException(paramName); + } + + return argument; + } + + /// + /// Throws an if the specified argument is , + /// or if the specified member is . + /// + /// Argument type to be checked for . + /// Member type to be checked for . + /// Argument to be checked for . + /// Object member to be checked for . + /// The name of the parameter being checked. + /// The name of the member. + /// The original value of . + /// + /// + /// Throws.IfNullOrMemberNull(myObject, myObject?.MyProperty) + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: NotNull] + public static TMember IfNullOrMemberNull( + [NotNull] TParameter argument, + [NotNull] TMember member, + [CallerArgumentExpression(nameof(argument))] string paramName = "", + [CallerArgumentExpression(nameof(member))] string memberName = "") + { + if (argument is null) + { + ArgumentNullException(paramName); + } + + if (member is null) + { + ArgumentException(paramName, $"Member {memberName} of {paramName} is null"); + } + + return member; + } + + /// + /// Throws an if the specified member is . + /// + /// Argument type. + /// Member type to be checked for . + /// Argument to which member belongs. + /// Object member to be checked for . + /// The name of the parameter being checked. + /// The name of the member. + /// The original value of . + /// + /// + /// Throws.IfMemberNull(myObject, myObject.MyProperty) + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: NotNull] + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Analyzer isn't seeing the reference to 'argument' in the attribute")] + public static TMember IfMemberNull( + TParameter argument, + [NotNull] TMember member, + [CallerArgumentExpression(nameof(argument))] string paramName = "", + [CallerArgumentExpression(nameof(member))] string memberName = "") + where TParameter : notnull + { + if (member is null) + { + ArgumentException(paramName, $"Member {memberName} of {paramName} is null"); + } + + return member; + } + + #endregion + + #region For String + + /// + /// Throws either an or an + /// if the specified string is or whitespace respectively. + /// + /// String to be checked for or whitespace. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: NotNull] + public static string IfNullOrWhitespace([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { +#if !NETCOREAPP3_1_OR_GREATER + if (argument == null) + { + ArgumentNullException(paramName); + } +#endif + + if (string.IsNullOrWhiteSpace(argument)) + { + if (argument == null) + { + ArgumentNullException(paramName); + } + else + { + ArgumentException(paramName, "Argument is whitespace"); + } + } + + return argument; + } + + /// + /// Throws an if the string is , + /// or if it is empty. + /// + /// String to be checked for or empty. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: NotNull] + public static string IfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { +#if !NETCOREAPP3_1_OR_GREATER + if (argument == null) + { + ArgumentNullException(paramName); + } +#endif + + if (string.IsNullOrEmpty(argument)) + { + if (argument == null) + { + ArgumentNullException(paramName); + } + else + { + ArgumentException(paramName, "Argument is an empty string"); + } + } + + return argument; + } + + #endregion + + #region For Buffer + + /// + /// Throws an if the argument's buffer size is less than the required buffer size. + /// + /// The actual buffer size. + /// The required buffer size. + /// The name of the parameter to be checked. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void IfBufferTooSmall(int bufferSize, int requiredSize, string paramName = "") + { + if (bufferSize < requiredSize) + { + ArgumentException(paramName, $"Buffer too small, needed a size of {requiredSize} but got {bufferSize}"); + } + } + + #endregion + + #region For Enums + + /// + /// Throws an if the enum value is not valid. + /// + /// The argument to evaluate. + /// The name of the parameter being checked. + /// The type of the enumeration. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T IfOutOfRange(T argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + where T : struct, Enum + { +#if NET5_0_OR_GREATER + if (!Enum.IsDefined(argument)) +#else + if (!Enum.IsDefined(typeof(T), argument)) +#endif + { + ArgumentOutOfRangeException(paramName, $"{argument} is an invalid value for enum type {typeof(T)}"); + } + + return argument; + } + + #endregion + + #region For Collections + + /// + /// Throws an if the collection is , + /// or if it is empty. + /// + /// The collection to evaluate. + /// The name of the parameter being checked. + /// The type of objects in the collection. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: NotNull] + + // The method has actually 100% coverage, but due to a bug in the code coverage tool, + // a lower number is reported. Therefore, we temporarily exclude this method + // from the coverage measurements. Once the bug in the code coverage tool is fixed, + // the exclusion attribute can be removed. + [ExcludeFromCodeCoverage] + public static IEnumerable IfNullOrEmpty([NotNull] IEnumerable? argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument == null) + { + ArgumentNullException(paramName); + } + else + { + switch (argument) + { + case ICollection collection: + if (collection.Count == 0) + { + ArgumentException(paramName, "Collection is empty"); + } + + break; + case IReadOnlyCollection readOnlyCollection: + if (readOnlyCollection.Count == 0) + { + ArgumentException(paramName, "Collection is empty"); + } + + break; + default: + using (IEnumerator enumerator = argument.GetEnumerator()) + { + if (!enumerator.MoveNext()) + { + ArgumentException(paramName, "Collection is empty"); + } + } + + break; + } + } + + return argument; + } + + #endregion + + #region Exceptions + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentNullException(string paramName) + => throw new ArgumentNullException(paramName); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentNullException(string paramName, string? message) + => throw new ArgumentNullException(paramName, message); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentOutOfRangeException(string paramName) + => throw new ArgumentOutOfRangeException(paramName); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentOutOfRangeException(string paramName, string? message) + => throw new ArgumentOutOfRangeException(paramName, message); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. + /// The value of the argument that caused this exception. + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentOutOfRangeException(string paramName, object? actualValue, string? message) + => throw new ArgumentOutOfRangeException(paramName, actualValue, message); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentException(string paramName, string? message) + => throw new ArgumentException(message, paramName); + + /// + /// Throws an . + /// + /// The name of the parameter that caused the exception. + /// A message that describes the error. + /// The exception that is the cause of the current exception. + /// + /// If the is not a , the current exception is raised in a catch + /// block that handles the inner exception. + /// +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void ArgumentException(string paramName, string? message, Exception? innerException) + => throw new ArgumentException(message, paramName, innerException); + + /// + /// Throws an . + /// + /// A message that describes the error. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void InvalidOperationException(string message) + => throw new InvalidOperationException(message); + + /// + /// Throws an . + /// + /// A message that describes the error. + /// The exception that is the cause of the current exception. +#if !NET6_0_OR_GREATER + [MethodImpl(MethodImplOptions.NoInlining)] +#endif + [DoesNotReturn] + public static void InvalidOperationException(string message, Exception? innerException) + => throw new InvalidOperationException(message, innerException); + + #endregion + + #region For Integer + + /// + /// Throws an if the specified number is less than min. + /// + /// Number to be expected being less than min. + /// The number that must be less than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IfLessThan(int argument, int min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater than max. + /// + /// Number to be expected being greater than max. + /// The number that must be greater than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IfGreaterThan(int argument, int max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is less or equal than min. + /// + /// Number to be expected being less or equal than min. + /// The number that must be less or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IfLessThanOrEqual(int argument, int min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument <= min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less or equal than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater or equal than max. + /// + /// Number to be expected being greater or equal than max. + /// The number that must be greater or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IfGreaterThanOrEqual(int argument, int max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument >= max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater or equal than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is not in the specified range. + /// + /// Number to be expected being greater or equal than max. + /// The lower bound of the allowed range of argument values. + /// The upper bound of the allowed range of argument values. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IfOutOfRange(int argument, int min, int max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min || argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument not in the range [{min}..{max}]"); + } + + return argument; + } + + /// + /// Throws an if the specified number is equal to 0. + /// + /// Number to be expected being not equal to zero. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IfZero(int argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument == 0) + { + ArgumentOutOfRangeException(paramName, "Argument is zero"); + } + + return argument; + } + + #endregion + + #region For Unsigned Integer + + /// + /// Throws an if the specified number is less than min. + /// + /// Number to be expected being less than min. + /// The number that must be less than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint IfLessThan(uint argument, uint min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater than max. + /// + /// Number to be expected being greater than max. + /// The number that must be greater than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint IfGreaterThan(uint argument, uint max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is less or equal than min. + /// + /// Number to be expected being less or equal than min. + /// The number that must be less or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint IfLessThanOrEqual(uint argument, uint min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument <= min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less or equal than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater or equal than max. + /// + /// Number to be expected being greater or equal than max. + /// The number that must be greater or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint IfGreaterThanOrEqual(uint argument, uint max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument >= max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater or equal than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is not in the specified range. + /// + /// Number to be expected being greater or equal than max. + /// The lower bound of the allowed range of argument values. + /// The upper bound of the allowed range of argument values. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint IfOutOfRange(uint argument, uint min, uint max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min || argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument not in the range [{min}..{max}]"); + } + + return argument; + } + + /// + /// Throws an if the specified number is equal to 0. + /// + /// Number to be expected being not equal to zero. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint IfZero(uint argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument == 0U) + { + ArgumentOutOfRangeException(paramName, "Argument is zero"); + } + + return argument; + } + + #endregion + + #region For Long + + /// + /// Throws an if the specified number is less than min. + /// + /// Number to be expected being less than min. + /// The number that must be less than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long IfLessThan(long argument, long min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater than max. + /// + /// Number to be expected being greater than max. + /// The number that must be greater than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long IfGreaterThan(long argument, long max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is less or equal than min. + /// + /// Number to be expected being less or equal than min. + /// The number that must be less or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long IfLessThanOrEqual(long argument, long min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument <= min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less or equal than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater or equal than max. + /// + /// Number to be expected being greater or equal than max. + /// The number that must be greater or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long IfGreaterThanOrEqual(long argument, long max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument >= max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater or equal than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is not in the specified range. + /// + /// Number to be expected being greater or equal than max. + /// The lower bound of the allowed range of argument values. + /// The upper bound of the allowed range of argument values. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long IfOutOfRange(long argument, long min, long max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min || argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument not in the range [{min}..{max}]"); + } + + return argument; + } + + /// + /// Throws an if the specified number is equal to 0. + /// + /// Number to be expected being not equal to zero. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long IfZero(long argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument == 0L) + { + ArgumentOutOfRangeException(paramName, "Argument is zero"); + } + + return argument; + } + + #endregion + + #region For Unsigned Long + + /// + /// Throws an if the specified number is less than min. + /// + /// Number to be expected being less than min. + /// The number that must be less than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong IfLessThan(ulong argument, ulong min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater than max. + /// + /// Number to be expected being greater than max. + /// The number that must be greater than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong IfGreaterThan(ulong argument, ulong max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is less or equal than min. + /// + /// Number to be expected being less or equal than min. + /// The number that must be less or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong IfLessThanOrEqual(ulong argument, ulong min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument <= min) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less or equal than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater or equal than max. + /// + /// Number to be expected being greater or equal than max. + /// The number that must be greater or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong IfGreaterThanOrEqual(ulong argument, ulong max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument >= max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater or equal than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is not in the specified range. + /// + /// Number to be expected being greater or equal than max. + /// The lower bound of the allowed range of argument values. + /// The upper bound of the allowed range of argument values. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong IfOutOfRange(ulong argument, ulong min, ulong max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument < min || argument > max) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument not in the range [{min}..{max}]"); + } + + return argument; + } + + /// + /// Throws an if the specified number is equal to 0. + /// + /// Number to be expected being not equal to zero. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong IfZero(ulong argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + if (argument == 0UL) + { + ArgumentOutOfRangeException(paramName, "Argument is zero"); + } + + return argument; + } + + #endregion + + #region For Double + + /// + /// Throws an if the specified number is less than min. + /// + /// Number to be expected being less than min. + /// The number that must be less than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double IfLessThan(double argument, double min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + // strange conditional needed in order to handle NaN values correctly +#pragma warning disable S1940 // Boolean checks should not be inverted + if (!(argument >= min)) +#pragma warning restore S1940 // Boolean checks should not be inverted + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater than max. + /// + /// Number to be expected being greater than max. + /// The number that must be greater than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double IfGreaterThan(double argument, double max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + // strange conditional needed in order to handle NaN values correctly +#pragma warning disable S1940 // Boolean checks should not be inverted + if (!(argument <= max)) +#pragma warning restore S1940 // Boolean checks should not be inverted + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is less or equal than min. + /// + /// Number to be expected being less or equal than min. + /// The number that must be less or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double IfLessThanOrEqual(double argument, double min, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + // strange conditional needed in order to handle NaN values correctly +#pragma warning disable S1940 // Boolean checks should not be inverted + if (!(argument > min)) +#pragma warning restore S1940 // Boolean checks should not be inverted + { + ArgumentOutOfRangeException(paramName, argument, $"Argument less or equal than minimum value {min}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is greater or equal than max. + /// + /// Number to be expected being greater or equal than max. + /// The number that must be greater or equal than the argument. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double IfGreaterThanOrEqual(double argument, double max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + // strange conditional needed in order to handle NaN values correctly +#pragma warning disable S1940 // Boolean checks should not be inverted + if (!(argument < max)) +#pragma warning restore S1940 // Boolean checks should not be inverted + { + ArgumentOutOfRangeException(paramName, argument, $"Argument greater or equal than maximum value {max}"); + } + + return argument; + } + + /// + /// Throws an if the specified number is not in the specified range. + /// + /// Number to be expected being greater or equal than max. + /// The lower bound of the allowed range of argument values. + /// The upper bound of the allowed range of argument values. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double IfOutOfRange(double argument, double min, double max, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { + // strange conditional needed in order to handle NaN values correctly + if (!(min <= argument && argument <= max)) + { + ArgumentOutOfRangeException(paramName, argument, $"Argument not in the range [{min}..{max}]"); + } + + return argument; + } + + /// + /// Throws an if the specified number is equal to 0. + /// + /// Number to be expected being not equal to zero. + /// The name of the parameter being checked. + /// The original value of . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double IfZero(double argument, [CallerArgumentExpression(nameof(argument))] string paramName = "") + { +#pragma warning disable S1244 // Floating point numbers should not be tested for equality + if (argument == 0.0) +#pragma warning restore S1244 // Floating point numbers should not be tested for equality + { + ArgumentOutOfRangeException(paramName, "Argument is zero"); + } + + return argument; + } + + #endregion +} diff --git a/src/Extensions.Grok/GrokChatClient.cs b/src/Extensions.Grok/GrokChatClient.cs new file mode 100644 index 0000000..6c3c473 --- /dev/null +++ b/src/Extensions.Grok/GrokChatClient.cs @@ -0,0 +1,451 @@ +using System.Text.Json; +using Devlooped.Grok; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.Extensions.AI; +using static Devlooped.Grok.Chat; + +namespace Devlooped.Extensions.AI.Grok; + +class GrokChatClient : IChatClient +{ + readonly ChatClientMetadata metadata; + readonly ChatClient client; + readonly string defaultModelId; + readonly GrokClientOptions clientOptions; + + internal GrokChatClient(GrpcChannel channel, GrokClientOptions clientOptions, string defaultModelId) + { + client = new ChatClient(channel); + this.clientOptions = clientOptions; + this.defaultModelId = defaultModelId; + metadata = new ChatClientMetadata("xai", clientOptions.Endpoint, defaultModelId); + } + + public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + var request = MapToRequest(messages, options); + var response = await client.GetCompletionAsync(request, cancellationToken: cancellationToken); + var lastOutput = response.Outputs.OrderByDescending(x => x.Index).FirstOrDefault(); + + if (lastOutput == null) + { + return new ChatResponse() + { + ResponseId = response.Id, + ModelId = response.Model, + CreatedAt = response.Created.ToDateTimeOffset(), + Usage = MapToUsage(response.Usage), + }; + } + + var message = new ChatMessage(MapRole(lastOutput.Message.Role), default(string)); + var citations = response.Citations?.Distinct().Select(MapCitation).ToList(); + + foreach (var output in response.Outputs.OrderBy(x => x.Index)) + { + if (output.Message.Content is { Length: > 0 } text) + { + // Special-case output from tools + if (output.Message.Role == MessageRole.RoleTool && + output.Message.ToolCalls.Count == 1 && + output.Message.ToolCalls[0] is { } toolCall) + { + if (toolCall.Type == ToolCallType.McpTool) + { + message.Contents.Add(new McpServerToolCallContent(toolCall.Id, toolCall.Function.Name, null) + { + RawRepresentation = toolCall + }); + message.Contents.Add(new McpServerToolResultContent(toolCall.Id) + { + RawRepresentation = toolCall, + Output = [new TextContent(text)] + }); + continue; + } + else if (toolCall.Type == ToolCallType.CodeExecutionTool) + { + message.Contents.Add(new CodeInterpreterToolCallContent() + { + CallId = toolCall.Id, + RawRepresentation = toolCall + }); + message.Contents.Add(new CodeInterpreterToolResultContent() + { + CallId = toolCall.Id, + RawRepresentation = toolCall, + Outputs = [new TextContent(text)] + }); + continue; + } + } + + var content = new TextContent(text) { Annotations = citations }; + + foreach (var citation in output.Message.Citations) + (content.Annotations ??= []).Add(MapInlineCitation(citation)); + + message.Contents.Add(content); + } + + foreach (var toolCall in output.Message.ToolCalls) + message.Contents.Add(MapToolCall(toolCall)); + } + + return new ChatResponse(message) + { + ResponseId = response.Id, + ModelId = response.Model, + CreatedAt = response.Created.ToDateTimeOffset(), + FinishReason = lastOutput != null ? MapFinishReason(lastOutput.FinishReason) : null, + Usage = MapToUsage(response.Usage), + }; + } + + AIContent MapToolCall(ToolCall toolCall) => toolCall.Type switch + { + ToolCallType.ClientSideTool => new FunctionCallContent( + toolCall.Id, + toolCall.Function.Name, + !string.IsNullOrEmpty(toolCall.Function.Arguments) + ? JsonSerializer.Deserialize>(toolCall.Function.Arguments) + : null) + { + RawRepresentation = toolCall + }, + ToolCallType.McpTool => new McpServerToolCallContent(toolCall.Id, toolCall.Function.Name, null) + { + RawRepresentation = toolCall + }, + ToolCallType.CodeExecutionTool => new CodeInterpreterToolCallContent() + { + CallId = toolCall.Id, + RawRepresentation = toolCall + }, + _ => new HostedToolCallContent() + { + CallId = toolCall.Id, + RawRepresentation = toolCall + } + }; + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + return CompleteChatStreamingCore(messages, options, cancellationToken); + + async IAsyncEnumerable CompleteChatStreamingCore(IEnumerable messages, ChatOptions? options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + var request = MapToRequest(messages, options); + var call = client.GetCompletionChunk(request, cancellationToken: cancellationToken); + + await foreach (var chunk in call.ResponseStream.ReadAllAsync(cancellationToken)) + { + var output = chunk.Outputs[0]; + var text = output.Delta.Content is { Length: > 0 } delta ? delta : null; + + // Use positional arguments for ChatResponseUpdate + var update = new ChatResponseUpdate(MapRole(output.Delta.Role), text) + { + ResponseId = chunk.Id, + ModelId = chunk.Model, + CreatedAt = chunk.Created?.ToDateTimeOffset(), + FinishReason = output.FinishReason != FinishReason.ReasonInvalid ? MapFinishReason(output.FinishReason) : null, + }; + + if (chunk.Citations is { Count: > 0 } citations) + { + var textContent = update.Contents.OfType().FirstOrDefault(); + if (textContent == null) + { + textContent = new TextContent(string.Empty); + update.Contents.Add(textContent); + } + + foreach (var citation in citations.Distinct()) + (textContent.Annotations ??= []).Add(MapCitation(citation)); + } + + foreach (var toolCall in output.Delta.ToolCalls) + update.Contents.Add(MapToolCall(toolCall)); + + if (update.Contents.Any()) + yield return update; + } + } + } + + static CitationAnnotation MapInlineCitation(InlineCitation citation) => citation.CitationCase switch + { + InlineCitation.CitationOneofCase.WebCitation => new CitationAnnotation { Url = new(citation.WebCitation.Url) }, + InlineCitation.CitationOneofCase.XCitation => new CitationAnnotation { Url = new(citation.XCitation.Url) }, + InlineCitation.CitationOneofCase.CollectionsCitation => new CitationAnnotation + { + FileId = citation.CollectionsCitation.FileId, + Snippet = citation.CollectionsCitation.ChunkContent, + ToolName = "file_search", + }, + _ => new CitationAnnotation() + }; + + static CitationAnnotation MapCitation(string citation) + { + var url = new Uri(citation); + if (url.Scheme != "collections") + return new CitationAnnotation { Url = url }; + + // Special-case collection citations so we get better metadata + var collection = url.Host; + var file = url.AbsolutePath[7..]; + return new CitationAnnotation + { + ToolName = "collections_search", + FileId = file, + AdditionalProperties = new AdditionalPropertiesDictionary + { + { "collection_id", collection } + } + }; + } + + GetCompletionsRequest MapToRequest(IEnumerable messages, ChatOptions? options) + { + var request = new GetCompletionsRequest + { + // By default always include citations in the final output if available + Include = { IncludeOption.InlineCitations }, + Model = options?.ModelId ?? defaultModelId, + }; + + if ((options?.EndUserId ?? clientOptions.EndUserId) is { } user) request.User = user; + if (options?.MaxOutputTokens is { } maxTokens) request.MaxTokens = maxTokens; + if (options?.Temperature is { } temperature) request.Temperature = temperature; + if (options?.TopP is { } topP) request.TopP = topP; + if (options?.FrequencyPenalty is { } frequencyPenalty) request.FrequencyPenalty = frequencyPenalty; + if (options?.PresencePenalty is { } presencePenalty) request.PresencePenalty = presencePenalty; + + foreach (var message in messages) + { + var gmsg = new Message { Role = MapRole(message.Role) }; + + foreach (var content in message.Contents) + { + if (content is TextContent textContent && !string.IsNullOrEmpty(textContent.Text)) + { + gmsg.Content.Add(new Content { Text = textContent.Text }); + } + else if (content.RawRepresentation is ToolCall toolCall) + { + gmsg.ToolCalls.Add(toolCall); + } + else if (content is FunctionCallContent functionCall) + { + gmsg.ToolCalls.Add(new ToolCall + { + Id = functionCall.CallId, + Type = ToolCallType.ClientSideTool, + Function = new FunctionCall + { + Name = functionCall.Name, + Arguments = JsonSerializer.Serialize(functionCall.Arguments) + } + }); + } + else if (content is FunctionResultContent resultContent) + { + request.Messages.Add(new Message + { + Role = MessageRole.RoleTool, + Content = { new Content { Text = JsonSerializer.Serialize(resultContent.Result) ?? "null" } } + }); + } + else if (content is McpServerToolResultContent mcpResult && + mcpResult.RawRepresentation is ToolCall mcpToolCall && + mcpResult.Output is { Count: 1 } && + mcpResult.Output[0] is TextContent mcpText) + { + request.Messages.Add(new Message + { + Role = MessageRole.RoleTool, + ToolCalls = { mcpToolCall }, + Content = { new Content { Text = mcpText.Text } } + }); + } + else if (content is CodeInterpreterToolResultContent codeResult && + codeResult.RawRepresentation is ToolCall codeToolCall && + codeResult.Outputs is { Count: 1 } && + codeResult.Outputs[0] is TextContent codeText) + { + request.Messages.Add(new Message + { + Role = MessageRole.RoleTool, + ToolCalls = { codeToolCall }, + Content = { new Content { Text = codeText.Text } } + }); + } + } + + if (gmsg.Content.Count == 0 && gmsg.ToolCalls.Count == 0) + continue; + + // If we have only tool calls and no content, the gRPC enpoint fails, so add an empty one. + if (gmsg.Content.Count == 0) + gmsg.Content.Add(new Content()); + + request.Messages.Add(gmsg); + } + + IList includes = [IncludeOption.InlineCitations]; + if (options is GrokChatOptions grokOptions) + { + // NOTE: overrides our default include for inline citations, potentially. + request.Include.Clear(); + request.Include.AddRange(grokOptions.Include); + + if (grokOptions.Search.HasFlag(GrokSearch.X)) + { + (options.Tools ??= []).Insert(0, new GrokXSearchTool()); + } + else if (grokOptions.Search.HasFlag(GrokSearch.Web)) + { + (options.Tools ??= []).Insert(0, new GrokSearchTool()); + } + } + + if (options?.Tools is not null) + { + foreach (var tool in options.Tools) + { + if (tool is AIFunction functionTool) + { + var function = new Function + { + Name = functionTool.Name, + Description = functionTool.Description, + Parameters = JsonSerializer.Serialize(functionTool.JsonSchema) + }; + request.Tools.Add(new Tool { Function = function }); + } + else if (tool is HostedWebSearchTool webSearchTool) + { + if (webSearchTool is GrokXSearchTool xSearch) + { + var toolProto = new XSearch + { + EnableImageUnderstanding = xSearch.EnableImageUnderstanding, + EnableVideoUnderstanding = xSearch.EnableVideoUnderstanding, + }; + + if (xSearch.AllowedHandles is { } allowed) toolProto.AllowedXHandles.AddRange(allowed); + if (xSearch.ExcludedHandles is { } excluded) toolProto.ExcludedXHandles.AddRange(excluded); + if (xSearch.FromDate is { } from) toolProto.FromDate = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(from.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + if (xSearch.ToDate is { } to) toolProto.ToDate = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(to.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + + request.Tools.Add(new Tool { XSearch = toolProto }); + } + else if (webSearchTool is GrokSearchTool grokSearch) + { + var toolProto = new WebSearch + { + EnableImageUnderstanding = grokSearch.EnableImageUnderstanding, + }; + + if (grokSearch.AllowedDomains is { } allowed) toolProto.AllowedDomains.AddRange(allowed); + if (grokSearch.ExcludedDomains is { } excluded) toolProto.ExcludedDomains.AddRange(excluded); + + request.Tools.Add(new Tool { WebSearch = toolProto }); + } + else + { + request.Tools.Add(new Tool { WebSearch = new WebSearch() }); + } + } + else if (tool is HostedCodeInterpreterTool) + { + request.Tools.Add(new Tool { CodeExecution = new CodeExecution { } }); + } + else if (tool is HostedFileSearchTool fileSearch) + { + var toolProto = new CollectionsSearch(); + + if (fileSearch.Inputs?.OfType() is { } vectorStores) + toolProto.CollectionIds.AddRange(vectorStores.Select(x => x.VectorStoreId).Distinct()); + + if (fileSearch.MaximumResultCount is { } maxResults) + toolProto.Limit = maxResults; + + request.Tools.Add(new Tool { CollectionsSearch = toolProto }); + } + else if (tool is HostedMcpServerTool mcpTool) + { + request.Tools.Add(new Tool + { + Mcp = new MCP + { + Authorization = mcpTool.AuthorizationToken, + ServerLabel = mcpTool.ServerName, + ServerUrl = mcpTool.ServerAddress, + AllowedToolNames = { mcpTool.AllowedTools ?? Array.Empty() } + } + }); + } + } + } + + if (options?.ResponseFormat is ChatResponseFormatJson) + { + request.ResponseFormat = new ResponseFormat + { + FormatType = FormatType.JsonObject + }; + } + + return request; + } + + static MessageRole MapRole(ChatRole role) => role switch + { + _ when role == ChatRole.System => MessageRole.RoleSystem, + _ when role == ChatRole.User => MessageRole.RoleUser, + _ when role == ChatRole.Assistant => MessageRole.RoleAssistant, + _ when role == ChatRole.Tool => MessageRole.RoleTool, + _ => MessageRole.RoleUser + }; + + static ChatRole MapRole(MessageRole role) => role switch + { + MessageRole.RoleSystem => ChatRole.System, + MessageRole.RoleUser => ChatRole.User, + MessageRole.RoleAssistant => ChatRole.Assistant, + MessageRole.RoleTool => ChatRole.Tool, + _ => ChatRole.Assistant + }; + + static ChatFinishReason? MapFinishReason(FinishReason finishReason) => finishReason switch + { + FinishReason.ReasonStop => ChatFinishReason.Stop, + FinishReason.ReasonMaxLen => ChatFinishReason.Length, + FinishReason.ReasonToolCalls => ChatFinishReason.ToolCalls, + FinishReason.ReasonMaxContext => ChatFinishReason.Length, + FinishReason.ReasonTimeLimit => ChatFinishReason.Length, + _ => null + }; + + static UsageDetails? MapToUsage(SamplingUsage usage) => usage == null ? null : new() + { + InputTokenCount = usage.PromptTokens, + OutputTokenCount = usage.CompletionTokens, + TotalTokenCount = usage.TotalTokens + }; + + /// + public object? GetService(Type serviceType, object? serviceKey = null) => serviceType switch + { + Type t when t == typeof(ChatClientMetadata) => metadata, + Type t when t == typeof(GrokChatClient) => this, + _ => null + }; + + /// + public void Dispose() { } +} diff --git a/src/Extensions.Grok/GrokChatOptions.cs b/src/Extensions.Grok/GrokChatOptions.cs new file mode 100644 index 0000000..ba7bf65 --- /dev/null +++ b/src/Extensions.Grok/GrokChatOptions.cs @@ -0,0 +1,41 @@ +using System.ComponentModel; +using Devlooped.Grok; +using Microsoft.Extensions.AI; + +namespace Devlooped.Extensions.AI.Grok; + +/// Customizes Grok's agentic search tools. +/// See https://docs.x.ai/docs/guides/tools/search-tools. +[Flags] +public enum GrokSearch +{ + /// Disables agentic search capabilities. + None = 0, + /// Enables all available agentic search capabilities. + All = Web | X, + /// Allows the agent to search the web and browse pages. + Web = 1, + /// Allows the agent to perform keyword search, semantic search, user search, and thread fetch on X. + X = 2, + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("Use either GrokSearch.Web or GrokSearch.X")] + Auto = Web, + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("Use either GrokSearch.Web or GrokSearch.X")] + On = Web, + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("Use GrokSearch.None")] + Off = None +} + +/// Grok-specific chat options that extend the base . +public class GrokChatOptions : ChatOptions +{ + /// Configures Grok's agentic search capabilities. + /// See https://docs.x.ai/docs/guides/tools/search-tools. + public GrokSearch Search { get; set; } = GrokSearch.None; + + /// Additional outputs to include in responses. + /// Defaults to including . + public IList Include { get; set; } = [IncludeOption.InlineCitations]; +} diff --git a/src/Extensions.Grok/GrokClient.cs b/src/Extensions.Grok/GrokClient.cs new file mode 100644 index 0000000..607ad0e --- /dev/null +++ b/src/Extensions.Grok/GrokClient.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using Grpc.Net.Client; + +namespace Devlooped.Extensions.AI.Grok; + +/// Client for interacting with the Grok service. +/// The API key used for authentication. +/// The options used to configure the client. +public class GrokClient(string apiKey, GrokClientOptions options) +{ + static ConcurrentDictionary<(Uri, string), GrpcChannel> channels = []; + + /// Initializes a new instance of the class with default options. + public GrokClient(string apiKey) : this(apiKey, new GrokClientOptions()) { } + + /// Gets the API key used for authentication. + public string ApiKey { get; } = apiKey; + + /// Gets or sets the endpoint for the service. + public Uri Endpoint { get; set; } = options.Endpoint; + + /// Gets the options used to configure the client. + public GrokClientOptions Options { get; } = options; + + internal GrpcChannel Channel => channels.GetOrAdd((Endpoint, ApiKey), key => + { + var handler = new AuthenticationHeaderHandler(ApiKey) + { + InnerHandler = Options.ChannelOptions?.HttpHandler ?? new HttpClientHandler() + }; + + var options = Options.ChannelOptions ?? new GrpcChannelOptions(); + options.HttpHandler = handler; + + return GrpcChannel.ForAddress(Endpoint, options); + }); + + class AuthenticationHeaderHandler(string apiKey) : DelegatingHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + return base.SendAsync(request, cancellationToken); + } + } +} diff --git a/src/Extensions.Grok/GrokClientExtensions.cs b/src/Extensions.Grok/GrokClientExtensions.cs new file mode 100644 index 0000000..9784177 --- /dev/null +++ b/src/Extensions.Grok/GrokClientExtensions.cs @@ -0,0 +1,13 @@ +using System.ComponentModel; +using Microsoft.Extensions.AI; + +namespace Devlooped.Extensions.AI.Grok; + +/// Provides extension methods for . +[EditorBrowsable(EditorBrowsableState.Never)] +public static class GrokClientExtensions +{ + /// Creates a new from the specified using the given model as the default. + public static IChatClient AsIChatClient(this GrokClient client, string defaultModelId) + => new GrokChatClient(client.Channel, client.Options, defaultModelId); +} diff --git a/src/Extensions.Grok/GrokClientOptions.cs b/src/Extensions.Grok/GrokClientOptions.cs new file mode 100644 index 0000000..4aea877 --- /dev/null +++ b/src/Extensions.Grok/GrokClientOptions.cs @@ -0,0 +1,16 @@ +using Grpc.Net.Client; + +namespace Devlooped.Extensions.AI.Grok; + +/// Options for configuring the . +public class GrokClientOptions +{ + /// Gets or sets the service endpoint. + public Uri Endpoint { get; set; } = new("https://api.x.ai"); + + /// Gets or sets the gRPC channel options. + public GrpcChannelOptions? ChannelOptions { get; set; } + + /// Gets or sets the end user ID for the chat session. + public string? EndUserId { get; set; } +} diff --git a/src/Extensions.Grok/GrokSearchTool.cs b/src/Extensions.Grok/GrokSearchTool.cs new file mode 100644 index 0000000..602e1f9 --- /dev/null +++ b/src/Extensions.Grok/GrokSearchTool.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.AI; + +namespace Devlooped.Extensions.AI.Grok; + +/// Configures Grok's agentic search tool. +/// See https://docs.x.ai/docs/guides/tools/search-tools +public class GrokSearchTool : HostedWebSearchTool +{ + /// + public override string Name => "web_search"; + + /// + public override string Description => "Performs agentic web search"; + + /// Use to make the web search only perform the search and web browsing on web pages that fall within the specified domains. Can include a maximum of five domains. + public IList? AllowedDomains { get; set; } + + /// Use to prevent the model from including the specified domains in any web search tool invocations and from browsing any pages on those domains. Can include a maximum of five domains. + public IList? ExcludedDomains { get; set; } + + /// See https://docs.x.ai/docs/guides/tools/search-tools#enable-image-understanding + public bool EnableImageUnderstanding { get; set; } +} \ No newline at end of file diff --git a/src/Extensions.Grok/GrokXSearch.cs b/src/Extensions.Grok/GrokXSearch.cs new file mode 100644 index 0000000..4d42538 --- /dev/null +++ b/src/Extensions.Grok/GrokXSearch.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Devlooped.Extensions.AI.Grok; + +/// Configures Grok's agentic search tool for X. +/// See https://docs.x.ai/docs/guides/tools/search-tools#x-search-parameters +public class GrokXSearchTool : HostedWebSearchTool +{ + /// See https://docs.x.ai/docs/guides/tools/search-tools#only-consider-x-posts-from-specific-handles + [JsonPropertyName("allowed_x_handles")] + public IList? AllowedHandles { get; set; } + /// See https://docs.x.ai/docs/guides/tools/search-tools#exclude-x-posts-from-specific-handles + [JsonPropertyName("excluded_x_handles")] + public IList? ExcludedHandles { get; set; } + /// See https://docs.x.ai/docs/guides/tools/search-tools#date-range + public DateOnly? FromDate { get; set; } + /// See https://docs.x.ai/docs/guides/tools/search-tools#date-range + public DateOnly? ToDate { get; set; } + /// See https://docs.x.ai/docs/guides/tools/search-tools#enable-image-understanding-1 + public bool EnableImageUnderstanding { get; set; } + /// See https://docs.x.ai/docs/guides/tools/search-tools#enable-video-understanding + public bool EnableVideoUnderstanding { get; set; } +} \ No newline at end of file diff --git a/src/Extensions.Grok/HostedToolCallContent.cs b/src/Extensions.Grok/HostedToolCallContent.cs new file mode 100644 index 0000000..52bcafd --- /dev/null +++ b/src/Extensions.Grok/HostedToolCallContent.cs @@ -0,0 +1,13 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; + +namespace Devlooped.Extensions.AI; + +/// Represents a hosted tool agentic call. +/// The tool call details. +[Experimental("DEAI001")] +public class HostedToolCallContent : AIContent +{ + /// Gets or sets the tool call ID. + public virtual string? CallId { get; set; } +} diff --git a/src/Extensions.Grok/HostedToolResultContent.cs b/src/Extensions.Grok/HostedToolResultContent.cs new file mode 100644 index 0000000..272b55b --- /dev/null +++ b/src/Extensions.Grok/HostedToolResultContent.cs @@ -0,0 +1,18 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; + +namespace Devlooped.Extensions.AI; + +/// Represents a hosted tool agentic call. +/// The tool call details. +[DebuggerDisplay("{DebuggerDisplay,nq}")] +[Experimental("DEAI001")] +public class HostedToolResultContent : AIContent +{ + /// Gets or sets the tool call ID. + public virtual string? CallId { get; set; } + + /// Gets or sets the resulting contents from the tool. + public virtual IList? Outputs { get; set; } +} \ No newline at end of file diff --git a/src/Extensions/ChatExtensions.cs b/src/Extensions/ChatExtensions.cs index 97b58d0..c1581a9 100644 --- a/src/Extensions/ChatExtensions.cs +++ b/src/Extensions/ChatExtensions.cs @@ -23,10 +23,14 @@ public Task GetResponseAsync(Chat chat, ChatOptions? options = nul extension(ChatOptions options) { - /// - /// Sets the effort level for a reasoning AI model when generating responses, if supported - /// by the model. - /// + /// Gets or sets the end user ID for the chat session. + public string? EndUserId + { + get => (options.AdditionalProperties ??= []).TryGetValue("EndUserId", out var value) ? value as string : null; + set => (options.AdditionalProperties ??= [])["EndUserId"] = value; + } + + /// Sets the effort level for a reasoning AI model when generating responses, if supported by the model. public ReasoningEffort? ReasoningEffort { get => options.AdditionalProperties?.TryGetValue("reasoning_effort", out var value) == true && value is ReasoningEffort effort ? effort : null; @@ -44,9 +48,7 @@ public ReasoningEffort? ReasoningEffort } } - /// - /// Sets the level for a GPT-5 model when generating responses, if supported - /// + /// Sets the level for a GPT-5 model when generating responses, if supported public Verbosity? Verbosity { get => options.AdditionalProperties?.TryGetValue("verbosity", out var value) == true && value is Verbosity verbosity ? verbosity : null; diff --git a/src/Extensions/ConfigurableChatClient.cs b/src/Extensions/ConfigurableChatClient.cs index 47c9653..491fcb9 100644 --- a/src/Extensions/ConfigurableChatClient.cs +++ b/src/Extensions/ConfigurableChatClient.cs @@ -102,7 +102,7 @@ public IAsyncEnumerable GetStreamingResponseAsync(IEnumerabl Throw.IfNullOrEmpty(apikey, $"{section}:apikey"); IChatClient client = options.Endpoint?.Host == "api.x.ai" - ? new GrokChatClient(apikey, options.ModelId, options) + ? new GrokClient(apikey, configSection.Get() ?? new()).AsIChatClient(options.ModelId) : options.Endpoint?.Host == "ai.azure.com" ? new ChatCompletionsClient(options.Endpoint, new AzureKeyCredential(apikey), SetOptions(configSection)).AsIChatClient(options.ModelId) : options.Endpoint?.Host.EndsWith("openai.azure.com") == true diff --git a/src/Extensions/Extensions.csproj b/src/Extensions/Extensions.csproj index d162bda..722606b 100644 --- a/src/Extensions/Extensions.csproj +++ b/src/Extensions/Extensions.csproj @@ -1,7 +1,7 @@  - net8.0;net9.0;net10.0 + net8.0;net10.0 Preview Devlooped.Extensions.AI $(AssemblyName) @@ -19,31 +19,22 @@ - - - + + + - - - - - - - - - - - - - - - - - + + + + + + + + diff --git a/src/Extensions/Extensions/ChatOptionsExtensions.cs b/src/Extensions/Extensions/ChatOptionsExtensions.cs new file mode 100644 index 0000000..7fa7dbf --- /dev/null +++ b/src/Extensions/Extensions/ChatOptionsExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.AI; + +namespace Devlooped.Extensions.AI; + +/// Extensions for . +static partial class ChatOptionsExtensions +{ + extension(ChatOptions options) + { + /// Gets or sets the end user ID for the chat session. + public string? EndUserId + { + get => (options.AdditionalProperties ??= []).TryGetValue("EndUserId", out var value) ? value as string : null; + set => (options.AdditionalProperties ??= [])["EndUserId"] = value; + } + } +} \ No newline at end of file diff --git a/src/Extensions/Grok/GrokChatClient.cs b/src/Extensions/Grok/GrokChatClient.cs deleted file mode 100644 index 0a1c63c..0000000 --- a/src/Extensions/Grok/GrokChatClient.cs +++ /dev/null @@ -1,203 +0,0 @@ -using System.ClientModel; -using System.ClientModel.Primitives; -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.AI; -using OpenAI; - -namespace Devlooped.Extensions.AI.Grok; - -/// -/// An implementation for Grok. -/// -public partial class GrokChatClient : IChatClient -{ - readonly ConcurrentDictionary clients = new(); - readonly string modelId; - readonly ClientPipeline pipeline; - readonly OpenAIClientOptions options; - readonly ChatClientMetadata metadata; - - /// - /// Initializes the client with the specified API key, model ID, and optional OpenAI client options. - /// - public GrokChatClient(string apiKey, string modelId, OpenAIClientOptions? options = default) - { - this.modelId = modelId; - this.options = options ?? new(); - this.options.Endpoint ??= new Uri("https://api.x.ai/v1"); - metadata = new ChatClientMetadata("xai", this.options.Endpoint, modelId); - - // NOTE: by caching the pipeline, we speed up creation of new chat clients per model, - // since the pipeline will be the same for all of them. - pipeline = new OpenAIClient(new ApiKeyCredential(apiKey), options).Pipeline; - } - - /// - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellation = default) - => GetChatClient(options?.ModelId ?? modelId).GetResponseAsync(messages, SetOptions(options), cancellation); - - /// - public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellation = default) - => GetChatClient(options?.ModelId ?? modelId).GetStreamingResponseAsync(messages, SetOptions(options), cancellation); - - IChatClient GetChatClient(string modelId) => clients.GetOrAdd(modelId, model - => new PipelineClient(pipeline, options).GetChatClient(modelId).AsIChatClient()); - - static ChatOptions? SetOptions(ChatOptions? options) - { - if (options is null) - return null; - - options.RawRepresentationFactory = _ => - { - var result = new GrokCompletionOptions(); - var grok = options as GrokChatOptions; - var search = grok?.Search; - var tool = options.Tools?.OfType().FirstOrDefault(); - GrokChatWebSearchOptions? searchOptions = default; - - if (search is not null && tool is null) - { - searchOptions = new GrokChatWebSearchOptions - { - Mode = search.Value - }; - } - else if (tool is null && options.Tools?.OfType().FirstOrDefault() is { } web) - { - searchOptions = new GrokChatWebSearchOptions - { - Mode = GrokSearch.Auto, - Sources = [new GrokWebSource { Country = web.Country }] - }; - } - else if (tool is null && options.Tools?.OfType().FirstOrDefault() is not null) - { - searchOptions = new GrokChatWebSearchOptions - { - Mode = GrokSearch.Auto - }; - } - else if (tool is not null) - { - searchOptions = new GrokChatWebSearchOptions - { - Mode = tool.Mode, - FromDate = tool.FromDate, - ToDate = tool.ToDate, - MaxSearchResults = tool.MaxSearchResults, - Sources = tool.Sources, - ReturnCitations = tool.ReturnCitations - }; - } - - if (searchOptions is not null) - { - result.WebSearchOptions = searchOptions; - } - - if (grok?.ReasoningEffort != null) - { - result.ReasoningEffortLevel = grok.ReasoningEffort switch - { - ReasoningEffort.High => global::OpenAI.Chat.ChatReasoningEffortLevel.High, - // Grok does not support Medium, so we map it to Low too - _ => global::OpenAI.Chat.ChatReasoningEffortLevel.Low, - }; - } - - return result; - }; - - return options; - } - - void IDisposable.Dispose() { } - - /// - public object? GetService(Type serviceType, object? serviceKey = null) => serviceType switch - { - Type t when t == typeof(ChatClientMetadata) => metadata, - _ => null - }; - - // Allows creating the base OpenAIClient with a pre-created pipeline. - class PipelineClient(ClientPipeline pipeline, OpenAIClientOptions options) : OpenAIClient(pipeline, options) { } - - class GrokChatWebSearchOptions : global::OpenAI.Chat.ChatWebSearchOptions - { - public GrokSearch Mode { get; set; } = GrokSearch.Auto; - public DateOnly? FromDate { get; set; } - public DateOnly? ToDate { get; set; } - public int? MaxSearchResults { get; set; } - public IList? Sources { get; set; } - public bool? ReturnCitations { get; set; } - } - - [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull | JsonIgnoreCondition.WhenWritingDefault, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower -#if DEBUG - , WriteIndented = true -#endif - )] - [JsonSerializable(typeof(GrokChatWebSearchOptions))] - [JsonSerializable(typeof(GrokSearch))] - [JsonSerializable(typeof(GrokSource))] - [JsonSerializable(typeof(GrokRssSource))] - [JsonSerializable(typeof(GrokWebSource))] - [JsonSerializable(typeof(GrokNewsSource))] - [JsonSerializable(typeof(GrokXSource))] - partial class GrokJsonContext : JsonSerializerContext - { - static readonly Lazy options = new(CreateDefaultOptions); - - /// - /// Provides a pre-configured instance of that aligns with the context's settings. - /// - public static JsonSerializerOptions DefaultOptions { get => options.Value; } - - static JsonSerializerOptions CreateDefaultOptions() - { - JsonSerializerOptions options = new(Default.Options) - { - WriteIndented = Debugger.IsAttached, - Converters = - { - new JsonStringEnumConverter(new LowercaseNamingPolicy()), - }, - }; - - options.MakeReadOnly(); - return options; - } - - class LowercaseNamingPolicy : JsonNamingPolicy - { - public override string ConvertName(string name) => name.ToLowerInvariant(); - } - } - - class GrokCompletionOptions : global::OpenAI.Chat.ChatCompletionOptions - { - protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions? options) - { - var search = WebSearchOptions as GrokChatWebSearchOptions; - // This avoids writing the default `web_search_options` property - WebSearchOptions = null; - - base.JsonModelWriteCore(writer, options); - - if (search != null) - { - writer.WritePropertyName("search_parameters"); - JsonSerializer.Serialize(writer, search, GrokJsonContext.DefaultOptions); - } - } - } -} diff --git a/src/Extensions/Grok/GrokChatOptions.cs b/src/Extensions/Grok/GrokChatOptions.cs deleted file mode 100644 index b608abd..0000000 --- a/src/Extensions/Grok/GrokChatOptions.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.Extensions.AI; - -namespace Devlooped.Extensions.AI.Grok; - -/// -/// Grok-specific chat options that extend the base -/// with and properties. -/// -public class GrokChatOptions : ChatOptions -{ - /// - /// Configures Grok's live search capabilities. - /// See https://docs.x.ai/docs/guides/live-search. - /// - /// - /// A shortcut to adding a to the collection, - /// or the (which sets the behavior). - /// - public GrokSearch Search { get; set; } = GrokSearch.Auto; - - /// - /// Configures the reasoning effort level for Grok's responses. - /// See https://docs.x.ai/docs/guides/reasoning. - /// - public ReasoningEffort? ReasoningEffort { get; set; } -} diff --git a/src/Extensions/Grok/GrokClient.cs b/src/Extensions/Grok/GrokClient.cs deleted file mode 100644 index 036147b..0000000 --- a/src/Extensions/Grok/GrokClient.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.ClientModel; -using System.ClientModel.Primitives; -using System.Collections.Concurrent; -using Microsoft.Extensions.AI; -using OpenAI; - -namespace Devlooped.Extensions.AI.Grok; - -/// -/// Provides an OpenAI compability client for Grok. It's recommended you -/// use directly for chat-only scenarios. -/// -public class GrokClient(string apiKey, OpenAIClientOptions? options = null) - : OpenAIClient(new ApiKeyCredential(apiKey), EnsureEndpoint(options)) -{ - readonly ConcurrentDictionary clients = new(); - - /// - /// Initializes a new instance of the with the specified API key. - /// - public GrokClient(string apiKey) : this(apiKey, new()) { } - - IChatClient GetChatClientImpl(string model) => clients.GetOrAdd(model, key => new GrokChatClient(apiKey, key, options)); - - /// - /// Returns an adapter that surfaces an interface that - /// can be used directly in the pipeline builder. - /// - public override global::OpenAI.Chat.ChatClient GetChatClient(string model) => new GrokChatClientAdapter(this, model); - - static OpenAIClientOptions EnsureEndpoint(OpenAIClientOptions? options) - { - options ??= new(); - options.Endpoint ??= new Uri("https://api.x.ai/v1"); - return options; - } - - // This adapter is provided for compatibility with the documented usage for - // OpenAI in MEAI docs. Most typical case would be to just create an directly. - // This throws on any non-IChatClient invoked methods in the AsIChatClient adapter, and - // forwards the IChatClient methods to the GrokChatClient implementation which is cached per client. - class GrokChatClientAdapter(GrokClient client, string model) : global::OpenAI.Chat.ChatClient, IChatClient - { - void IDisposable.Dispose() { } - - object? IChatClient.GetService(Type serviceType, object? serviceKey) => client.GetChatClientImpl(model).GetService(serviceType, serviceKey); - - /// - /// Routes the request to a client that matches the options' ModelId (if set), or - /// the default model when the adapter was created. - /// - Task IChatClient.GetResponseAsync(IEnumerable messages, ChatOptions? options, CancellationToken cancellation) - => client.GetChatClientImpl(options?.ModelId ?? model).GetResponseAsync(messages, options, cancellation); - - /// - /// Routes the request to a client that matches the options' ModelId (if set), or - /// the default model when the adapter was created. - /// - IAsyncEnumerable IChatClient.GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options, CancellationToken cancellation) - => client.GetChatClientImpl(options?.ModelId ?? model).GetStreamingResponseAsync(messages, options, cancellation); - - // These are the only two methods actually invoked by the AsIChatClient adapter from M.E.AI.OpenAI - public override Task> CompleteChatAsync(IEnumerable? messages, global::OpenAI.Chat.ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)} instead of invoking {nameof(OpenAIClientExtensions.AsIChatClient)} on this instance."); - - public override AsyncCollectionResult CompleteChatStreamingAsync(IEnumerable? messages, global::OpenAI.Chat.ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)} instead of invoking {nameof(OpenAIClientExtensions.AsIChatClient)} on this instance."); - - #region Unsupported - - public override ClientResult CompleteChat(BinaryContent? content, RequestOptions? options = null) - => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); - - public override ClientResult CompleteChat(IEnumerable? messages, global::OpenAI.Chat.ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); - - public override ClientResult CompleteChat(params global::OpenAI.Chat.ChatMessage[] messages) - => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); - - public override Task CompleteChatAsync(BinaryContent? content, RequestOptions? options = null) - => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); - - public override Task> CompleteChatAsync(params global::OpenAI.Chat.ChatMessage[] messages) - => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); - - public override CollectionResult CompleteChatStreaming(IEnumerable? messages, global::OpenAI.Chat.ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) - => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); - - public override CollectionResult CompleteChatStreaming(params global::OpenAI.Chat.ChatMessage[] messages) - => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); - - public override AsyncCollectionResult CompleteChatStreamingAsync(params global::OpenAI.Chat.ChatMessage[] messages) - => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); - - #endregion - } -} - diff --git a/src/Extensions/Grok/GrokSearchTool.cs b/src/Extensions/Grok/GrokSearchTool.cs deleted file mode 100644 index 82cc445..0000000 --- a/src/Extensions/Grok/GrokSearchTool.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.Text.Json.Serialization; -using Microsoft.Extensions.AI; - -namespace Devlooped.Extensions.AI.Grok; - -/// -/// Enables or disables Grok's live search capabilities. -/// See https://docs.x.ai/docs/guides/live-search#enabling-search -/// -public enum GrokSearch -{ - /// - /// (default): Live search is available to the model, but the model automatically decides whether to perform live search. - /// - Auto, - /// - /// Enables live search. - /// - On, - /// - /// Disables search and uses the model without accessing additional information from data sources. - /// - Off -} - -/// Configures Grok's live search capabilities. See https://docs.x.ai/docs/guides/live-search. -public class GrokSearchTool(GrokSearch mode) : HostedWebSearchTool -{ - /// Sets the search mode for Grok's live search capabilities. - public GrokSearch Mode { get; } = mode; - /// - public override string Name => "Live Search"; - /// - public override string Description => "Performs live search using X.AI"; - /// See https://docs.x.ai/docs/guides/live-search#set-date-range-of-the-search-data - public DateOnly? FromDate { get; set; } - /// See https://docs.x.ai/docs/guides/live-search#set-date-range-of-the-search-data - public DateOnly? ToDate { get; set; } - /// See https://docs.x.ai/docs/guides/live-search#limit-the-maximum-amount-of-data-sources - public int? MaxSearchResults { get; set; } - /// See https://docs.x.ai/docs/guides/live-search#data-sources-and-parameters - public IList? Sources { get; set; } - /// See https://docs.x.ai/docs/guides/live-search#returning-citations - public bool? ReturnCitations { get; set; } -} - -/// Grok Live Search data source base type. -[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] -[JsonDerivedType(typeof(GrokWebSource), "web")] -[JsonDerivedType(typeof(GrokNewsSource), "news")] -[JsonDerivedType(typeof(GrokRssSource), "rss")] -[JsonDerivedType(typeof(GrokXSource), "x")] -public abstract class GrokSource { } - -/// Search-based data source base class providing common properties for `web` and `news` sources. -public abstract class GrokSearchSource : GrokSource -{ - /// Include data from a specific country/region by specifying the ISO alpha-2 code of the country. - public string? Country { get; set; } - /// See https://docs.x.ai/docs/guides/live-search#parameter-safe_search-supported-by-web-and-news - public bool? SafeSearch { get; set; } - /// See https://docs.x.ai/docs/guides/live-search#parameter-excluded_websites-supported-by-web-and-news - public IList? ExcludedWebsites { get; set; } -} - -/// Web live search source. -public class GrokWebSource : GrokSearchSource -{ - /// See https://docs.x.ai/docs/guides/live-search#parameter-allowed_websites-supported-by-web - public IList? AllowedWebsites { get; set; } -} - -/// News live search source. -public class GrokNewsSource : GrokSearchSource { } - -/// RSS live search source. -/// The RSS feed to search. -public class GrokRssSource(string rss) : GrokSource -{ - /// See https://docs.x.ai/docs/guides/live-search#parameter-link-supported-by-rss - public IList? Links { get; set; } = [rss]; -} - -/// X live search source./summary> -public class GrokXSource : GrokSearchSource -{ - /// See https://docs.x.ai/docs/guides/live-search#parameter-excluded_x_handles-supported-by-x - [JsonPropertyName("excluded_x_handles")] - public IList? ExcludedHandles { get; set; } - /// See https://docs.x.ai/docs/guides/live-search#parameter-included_x_handles-supported-by-x - [JsonPropertyName("included_x_handles")] - public IList? IncludedHandles { get; set; } - /// See https://docs.x.ai/docs/guides/live-search#parameters-post_favorite_count-and-post_view_count-supported-by-x - [JsonPropertyName("post_favorite_count")] - public int? FavoriteCount { get; set; } - /// See https://docs.x.ai/docs/guides/live-search#parameters-post_favorite_count-and-post_view_count-supported-by-x - [JsonPropertyName("post_view_count")] - public int? ViewCount { get; set; } -} \ No newline at end of file diff --git a/src/Extensions/Visibility.cs b/src/Extensions/Visibility.cs new file mode 100644 index 0000000..8e43161 --- /dev/null +++ b/src/Extensions/Visibility.cs @@ -0,0 +1,3 @@ +namespace Devlooped.Extensions.AI; + +public partial class ChatOptionsExtensions { } \ No newline at end of file diff --git a/src/Tests/ConfigurableAgentTests.cs b/src/Tests/ConfigurableAgentTests.cs index 7df9f2e..89116a0 100644 --- a/src/Tests/ConfigurableAgentTests.cs +++ b/src/Tests/ConfigurableAgentTests.cs @@ -290,43 +290,43 @@ public void CanSetOpenAIReasoningAndVerbosity() Assert.Equal(ReasoningEffort.Minimal, options?.ChatOptions?.ReasoningEffort); } - [Fact] - public void CanSetGrokOptions() - { - var builder = new HostApplicationBuilder(); - - builder.Configuration.AddInMemoryCollection(new Dictionary - { - ["ai:clients:grok:modelid"] = "grok-4", - ["ai:clients:grok:apikey"] = "xai-asdfasdf", - ["ai:clients:grok:endpoint"] = "https://api.x.ai", - ["ai:agents:bot:client"] = "grok", - ["ai:agents:bot:options:reasoningeffort"] = "low", - ["ai:agents:bot:options:search"] = "auto", - }); - - builder.AddAIAgents(); - var app = builder.Build(); - var agent = app.Services.GetRequiredKeyedService("bot"); - var options = agent.GetService(); - - var grok = Assert.IsType(options?.ChatOptions); - - Assert.Equal(ReasoningEffort.Low, grok.ReasoningEffort); - Assert.Equal(GrokSearch.Auto, grok.Search); - - var configuration = (IConfigurationRoot)app.Services.GetRequiredService(); - configuration["ai:agents:bot:options:reasoningeffort"] = "high"; - configuration["ai:agents:bot:options:search"] = "off"; - // NOTE: the in-memory provider does not support reload on change, so we must trigger it manually. - configuration.Reload(); - - options = agent.GetService(); - grok = Assert.IsType(options?.ChatOptions); - - Assert.Equal(ReasoningEffort.High, grok.ReasoningEffort); - Assert.Equal(GrokSearch.Off, grok.Search); - } + //[Fact] + //public void CanSetGrokOptions() + //{ + // var builder = new HostApplicationBuilder(); + + // builder.Configuration.AddInMemoryCollection(new Dictionary + // { + // ["ai:clients:grok:modelid"] = "grok-4", + // ["ai:clients:grok:apikey"] = "xai-asdfasdf", + // ["ai:clients:grok:endpoint"] = "https://api.x.ai", + // ["ai:agents:bot:client"] = "grok", + // ["ai:agents:bot:options:reasoningeffort"] = "low", + // ["ai:agents:bot:options:search"] = "auto", + // }); + + // builder.AddAIAgents(); + // var app = builder.Build(); + // var agent = app.Services.GetRequiredKeyedService("bot"); + // var options = agent.GetService(); + + // var grok = Assert.IsType(options?.ChatOptions); + + // Assert.Equal(ReasoningEffort.Low, grok.ReasoningEffort); + // Assert.Equal(GrokSearch.Auto, grok.Search); + + // var configuration = (IConfigurationRoot)app.Services.GetRequiredService(); + // configuration["ai:agents:bot:options:reasoningeffort"] = "high"; + // configuration["ai:agents:bot:options:search"] = "off"; + // // NOTE: the in-memory provider does not support reload on change, so we must trigger it manually. + // configuration.Reload(); + + // options = agent.GetService(); + // grok = Assert.IsType(options?.ChatOptions); + + // Assert.Equal(ReasoningEffort.High, grok.ReasoningEffort); + // Assert.Equal(GrokSearch.Off, grok.Search); + //} [Fact] public void UseContextProviderFactoryFromKeyedService() diff --git a/src/Tests/ConfigurableClientTests.cs b/src/Tests/ConfigurableClientTests.cs index 2a3b37b..df98300 100644 --- a/src/Tests/ConfigurableClientTests.cs +++ b/src/Tests/ConfigurableClientTests.cs @@ -4,7 +4,7 @@ namespace Devlooped.Extensions.AI; -public class ConfigurableTests(ITestOutputHelper output) +public class ConfigurableClientTests(ITestOutputHelper output) { [Fact] public void CanConfigureClients() diff --git a/src/Tests/DotEnv.cs b/src/Tests/DotEnv.cs new file mode 100644 index 0000000..a74580b --- /dev/null +++ b/src/Tests/DotEnv.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; + +class DotEnv +{ + [ModuleInitializer] + public static void Init() + { + // Load environment variables from .env files in current dir and above. + DotNetEnv.Env.TraversePath().Load(); + + // Load environment variables from user profile directory. + var userEnv = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".env"); + if (File.Exists(userEnv)) + DotNetEnv.Env.Load(userEnv); + } +} \ No newline at end of file diff --git a/src/Tests/GrokTests.cs b/src/Tests/GrokTests.cs index f60fbe5..d27336f 100644 --- a/src/Tests/GrokTests.cs +++ b/src/Tests/GrokTests.cs @@ -1,6 +1,10 @@ -using System.Text.Json.Nodes; +using System.Text.Json; +using System.Text.Json.Nodes; +using Azure; using Devlooped.Extensions.AI.Grok; +using Devlooped.Grok; using Microsoft.Extensions.AI; +using OpenAI.Realtime; using static ConfigurationExtensions; using OpenAIClientOptions = OpenAI.OpenAIClientOptions; @@ -17,12 +21,14 @@ public async Task GrokInvokesTools() { "user", "What day is today?" }, }; - var chat = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-3"); + var chat = new GrokClient(Configuration["XAI_API_KEY"]!).AsIChatClient("grok-4") + .AsBuilder() + .UseLogging(output.AsLoggerFactory()) + .Build(); var options = new GrokChatOptions { - ModelId = "grok-3-mini", - Search = GrokSearch.Auto, + ModelId = "grok-4-fast-non-reasoning", Tools = [AIFunctionFactory.Create(() => DateTimeOffset.Now.ToString("O"), "get_date")], AdditionalProperties = new() { @@ -38,7 +44,7 @@ public async Task GrokInvokesTools() Assert.True(getdate); // NOTE: the chat client was requested as grok-3 but the chat options wanted a // different model and the grok client honors that choice. - Assert.Equal("grok-3-mini", response.ModelId); + Assert.Equal(options.ModelId, response.ModelId); } [SecretsFact("XAI_API_KEY")] @@ -46,53 +52,88 @@ public async Task GrokInvokesToolAndSearch() { var messages = new Chat() { - { "system", "You are a bot that invokes the tool 'get_date' before responding to anything since it's important context." }, + { "system", "You use Nasdaq for stocks news and prices." }, { "user", "What's Tesla stock worth today?" }, }; - var requests = new List(); - var responses = new List(); - - var grok = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-3", OpenAIClientOptions - .Observable(requests.Add, responses.Add) - .WriteTo(output)) + var grok = new GrokClient(Configuration["XAI_API_KEY"]!).AsIChatClient("grok-4") .AsBuilder() .UseFunctionInvocation() + .UseLogging(output.AsLoggerFactory()) .Build(); + var getDateCalls = 0; var options = new GrokChatOptions { - ModelId = "grok-3-mini", - Search = GrokSearch.On, - Tools = [AIFunctionFactory.Create(() => DateTimeOffset.Now.ToString("O"), "get_date")] + ModelId = "grok-4-1-fast-non-reasoning", + Search = GrokSearch.Web, + Tools = [AIFunctionFactory.Create(() => + { + getDateCalls++; + return DateTimeOffset.Now.ToString("O"); + }, "get_date", "Gets the current date")], }; var response = await grok.GetResponseAsync(messages, options); - // assert that the request contains the following node - // "search_parameters": { - // "mode": "on" - //} - Assert.All(requests, x => - { - var search = Assert.IsType(x["search_parameters"]); - Assert.Equal("on", search["mode"]?.GetValue()); - }); - // The get_date result shows up as a tool role Assert.Contains(response.Messages, x => x.Role == ChatRole.Tool); // Citations include nasdaq.com at least as a web search source - var node = responses.LastOrDefault(); - Assert.NotNull(node); - var citations = Assert.IsType(node["citations"], false); - var yahoo = citations.Where(x => x != null).Any(x => x!.ToString().Contains("https://finance.yahoo.com/quote/TSLA/", StringComparison.Ordinal)); + var urls = response.Messages + .SelectMany(x => x.Contents) + .SelectMany(x => x.Annotations?.OfType() ?? []) + .Where(x => x.Url is not null) + .Select(x => x.Url!) + .ToList(); + + Assert.Equal(1, getDateCalls); + Assert.Contains(urls, x => x.Host.EndsWith("nasdaq.com")); + Assert.Contains(urls, x => x.PathAndQuery.Contains("/TSLA")); + Assert.Equal(options.ModelId, response.ModelId); + + var calls = response.Messages + .SelectMany(x => x.Contents.OfType()) + .Select(x => x.RawRepresentation as Devlooped.Grok.ToolCall) + .Where(x => x is not null) + .ToList(); + + Assert.NotEmpty(calls); + Assert.Contains(calls, x => x?.Type == Devlooped.Grok.ToolCallType.WebSearchTool); + } - Assert.True(yahoo, "Expected at least one citation to nasdaq.com"); + [SecretsFact("XAI_API_KEY")] + public async Task GrokInvokesSpecificSearchUrl() + { + var messages = new Chat() + { + { "system", "Sos un asistente del Cerro Catedral, usas la funcionalidad de Live Search en el sitio oficial." }, + { "system", $"Hoy es {DateTime.Now.ToString("o")}" }, + { "user", "Que calidad de nieve hay hoy?" }, + }; - // NOTE: the chat client was requested as grok-3 but the chat options wanted a - // different model and the grok client honors that choice. - Assert.Equal("grok-3-mini", response.ModelId); + var grok = new GrokClient(Configuration["XAI_API_KEY"]!).AsIChatClient("grok-4-1-fast-non-reasoning"); + + var options = new ChatOptions + { + Tools = [new GrokSearchTool() + { + AllowedDomains = [ "catedralaltapatagonia.com" ] + }] + }; + + var response = await grok.GetResponseAsync(messages, options); + var text = response.Text; + + var citations = response.Messages + .SelectMany(x => x.Contents) + .SelectMany(x => x.Annotations ?? []) + .OfType() + .Where(x => x.Url != null) + .Select(x => x.Url!.AbsoluteUri) + .ToList(); + + Assert.Contains("https://partediario.catedralaltapatagonia.com/partediario/", citations); } [SecretsFact("XAI_API_KEY")] @@ -101,18 +142,14 @@ public async Task GrokInvokesHostedSearchTool() var messages = new Chat() { { "system", "You are an AI assistant that knows how to search the web." }, - { "user", "What's Tesla stock worth today? Search X and the news for latest info." }, + { "user", "What's Tesla stock worth today? Search X, Yahoo and the news for latest info." }, }; - var requests = new List(); - var responses = new List(); - - var grok = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-3", OpenAIClientOptions - .Observable(requests.Add, responses.Add) - .WriteTo(output)); + var grok = new GrokClient(Configuration["XAI_API_KEY"]!).AsIChatClient("grok-4-fast"); - var options = new ChatOptions + var options = new GrokChatOptions { + Include = { Devlooped.Grok.IncludeOption.WebSearchCallOutput }, Tools = [new HostedWebSearchTool()] }; @@ -120,181 +157,317 @@ public async Task GrokInvokesHostedSearchTool() var text = response.Text; Assert.Contains("TSLA", text); + Assert.NotNull(response.ModelId); + + var urls = response.Messages + .SelectMany(x => x.Contents) + .SelectMany(x => x.Annotations?.OfType() ?? []) + .Where(x => x.Url is not null) + .Select(x => x.Url!) + .ToList(); + + Assert.Contains(urls, x => x.Host == "finance.yahoo.com"); + Assert.Contains(urls, x => x.PathAndQuery.Contains("/TSLA")); + } + + [SecretsFact("XAI_API_KEY")] + public async Task GrokInvokesGrokSearchToolIncludesDomain() + { + var messages = new Chat() + { + { "system", "You are an AI assistant that knows how to search the web." }, + { "user", "What is the latest news about Microsoft?" }, + }; + + var grok = new GrokClient(Configuration["XAI_API_KEY"]!).AsIChatClient("grok-4-fast"); - // assert that the request contains the following node - // "search_parameters": { - // "mode": "auto" - //} - Assert.All(requests, x => + var options = new ChatOptions { - var search = Assert.IsType(x["search_parameters"]); - Assert.Equal("auto", search["mode"]?.GetValue()); - }); + Tools = [new GrokSearchTool + { + AllowedDomains = ["microsoft.com", "news.microsoft.com"], + }] + }; - // Citations include nasdaq.com at least as a web search source - Assert.Single(responses); - var node = responses[0]; - Assert.NotNull(node); - var citations = Assert.IsType(node["citations"], false); - var yahoo = citations.Where(x => x != null).Any(x => x!.ToString().Contains("https://finance.yahoo.com/quote/TSLA/", StringComparison.Ordinal)); + var response = await grok.GetResponseAsync(messages, options); + + Assert.NotNull(response.Text); + Assert.Contains("Microsoft", response.Text); - Assert.True(yahoo, "Expected at least one citation to nasdaq.com"); + var urls = response.Messages + .SelectMany(x => x.Contents) + .SelectMany(x => x.Annotations?.OfType() ?? []) + .Where(x => x.Url is not null) + .Select(x => x.Url!) + .ToList(); - // Uses the default model set by the client when we asked for it - Assert.Equal("grok-3", response.ModelId); + foreach (var url in urls) + { + output.WriteLine(url.ToString()); + } + + Assert.All(urls, x => x.Host.EndsWith(".microsoft.com")); } [SecretsFact("XAI_API_KEY")] - public async Task GrokThinksHard() + public async Task GrokInvokesGrokSearchToolExcludesDomain() { var messages = new Chat() { - { "system", "You are an intelligent AI assistant that's an expert on financial matters." }, - { "user", "If you have a debt of 100k and accumulate a compounding 5% debt on top of it every year, how long before you are a negative millonaire? (round up to full integer value)" }, + { "system", "You are an AI assistant that knows how to search the web." }, + { "user", "What is the latest news about Microsoft?" }, }; - var grok = new GrokClient(Configuration["XAI_API_KEY"]!) - .GetChatClient("grok-3") - .AsIChatClient(); + var grok = new GrokClient(Configuration["XAI_API_KEY"]!).AsIChatClient("grok-4-fast"); - var options = new GrokChatOptions + var options = new ChatOptions { - ModelId = "grok-3-mini", - Search = GrokSearch.Off, - ReasoningEffort = ReasoningEffort.High, + Tools = [new GrokSearchTool + { + ExcludedDomains = ["blogs.microsoft.com"] + }] }; var response = await grok.GetResponseAsync(messages, options); - var text = response.Text; + Assert.NotNull(response.Text); + Assert.Contains("Microsoft", response.Text); - Assert.Contains("48 years", text); - // NOTE: the chat client was requested as grok-3 but the chat options wanted a - // different model and the grok client honors that choice. - Assert.StartsWith("grok-3-mini", response.ModelId); + var urls = response.Messages + .SelectMany(x => x.Contents) + .SelectMany(x => x.Annotations?.OfType() ?? []) + .Where(x => x.Url is not null) + .Select(x => x.Url!) + .ToList(); + + foreach (var url in urls) + { + output.WriteLine(url.ToString()); + } + + Assert.DoesNotContain(urls, x => x.Host == "blogs.microsoft.com"); } [SecretsFact("XAI_API_KEY")] - public async Task GrokInvokesSpecificSearchUrl() + public async Task GrokInvokesHostedCodeExecution() { var messages = new Chat() { - { "system", "Sos un asistente del Cerro Catedral, usas la funcionalidad de Live Search en el sitio oficial." }, - { "system", $"Hoy es {DateTime.Now.ToString("o")}" }, - { "user", "Que calidad de nieve hay hoy?" }, + { "user", "Calculate the compound interest for $10,000 at 5% annually for 10 years" }, }; - var requests = new List(); - var responses = new List(); - - var grok = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-4-fast-non-reasoning", OpenAIClientOptions - .Observable(requests.Add, responses.Add) - .WriteTo(output)); + var grok = new GrokClient(Configuration["XAI_API_KEY"]!).AsIChatClient("grok-4-fast"); var options = new ChatOptions { - Tools = [new GrokSearchTool(GrokSearch.On) - { - //FromDate = new DateOnly(2025, 1, 1), - //ToDate = DateOnly.FromDateTime(DateTime.Now), - //MaxSearchResults = 10, - Sources = - [ - new GrokWebSource - { - AllowedWebsites = - [ - "https://catedralaltapatagonia.com", - "https://catedralaltapatagonia.com/parte-de-nieve/", - "https://catedralaltapatagonia.com/tarifas/" - ] - }, - ] - }] + Tools = [new HostedCodeInterpreterTool()] }; var response = await grok.GetResponseAsync(messages, options); var text = response.Text; - // assert that the request contains the following node - // "search_parameters": { - // "mode": "auto" - //} - Assert.All(requests, x => + Assert.Contains("$6,288.95", text); + Assert.NotEmpty(response.Messages + .SelectMany(x => x.Contents) + .OfType()); + + Assert.Empty(response.Messages + .SelectMany(x => x.Contents) + .OfType()); + } + + [SecretsFact("XAI_API_KEY")] + public async Task GrokInvokesHostedCodeExecutionWithOutput() + { + var messages = new Chat() { - var search = Assert.IsType(x["search_parameters"]); - Assert.Equal("on", search["mode"]?.GetValue()); - }); + { "user", "Calculate the compound interest for $10,000 at 5% annually for 10 years" }, + }; - // Citations include catedralaltapatagonia.com at least as a web search source - Assert.Single(responses); - var node = responses[0]; - Assert.NotNull(node); - var citations = Assert.IsType(node["citations"], false); - var catedral = citations.Where(x => x != null).Any(x => x!.ToString().Contains("catedralaltapatagonia.com", StringComparison.Ordinal)); + var grok = new GrokClient(Configuration["XAI_API_KEY"]!).AsIChatClient("grok-4-fast"); - Assert.True(catedral, "Expected at least one citation to catedralaltapatagonia.com"); + var options = new GrokChatOptions + { + Include = { Devlooped.Grok.IncludeOption.CodeExecutionCallOutput }, + Tools = [new HostedCodeInterpreterTool()] + }; - // Uses the default model set by the client when we asked for it - Assert.Equal("grok-4-fast-non-reasoning", response.ModelId); + var response = await grok.GetResponseAsync(messages, options); + var text = response.Text; + + Assert.Contains("$6,288.95", text); + Assert.NotEmpty(response.Messages + .SelectMany(x => x.Contents) + .OfType()); + + Assert.NotEmpty(response.Messages + .SelectMany(x => x.Contents) + .OfType()); } [SecretsFact("XAI_API_KEY")] - public async Task CanAvoidCitations() + public async Task GrokInvokesHostedCollectionSearch() { var messages = new Chat() { - { "system", "Sos un asistente del Cerro Catedral, usas la funcionalidad de Live Search en el sitio oficial." }, - { "system", $"Hoy es {DateTime.Now.ToString("o")}" }, - { "user", "Que calidad de nieve hay hoy?" }, + { "user", "¿Cuál es el monto exacto del rango de la multa por inasistencia injustificada a la audiencia señalada por el juez en el proceso sucesorio, según lo establecido en el Artículo 691 del Código Procesal Civil y Comercial de la Nación (Ley 17.454)?" }, }; - var requests = new List(); - var responses = new List(); + var grok = new GrokClient(Configuration["XAI_API_KEY"]!).AsIChatClient("grok-4-fast"); - var grok = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-4-fast-non-reasoning", OpenAIClientOptions - .Observable(requests.Add, responses.Add) - .WriteTo(output)); + var options = new ChatOptions + { + Tools = [new HostedFileSearchTool { + Inputs = [new HostedVectorStoreContent("collection_91559d9b-a55d-42fe-b2ad-ecf8904d9049")] + }] + }; + + var response = await grok.GetResponseAsync(messages, options); + var text = response.Text; + + Assert.Contains("11,74", text); + Assert.Contains(response.Messages + .SelectMany(x => x.Contents) + .OfType() + .Select(x => x.RawRepresentation as Devlooped.Grok.ToolCall), + x => x?.Type == Devlooped.Grok.ToolCallType.CollectionsSearchTool); + } + + [SecretsFact("XAI_API_KEY", "GITHUB_TOKEN")] + public async Task GrokInvokesHostedMcp() + { + var messages = new Chat() + { + { "user", "When was GrokClient v1.0.0 released on the devlooped/GrokClient repo? Respond with just the date, in YYYY-MM-DD format." }, + }; + + var grok = new GrokClient(Configuration["XAI_API_KEY"]!).AsIChatClient("grok-4-fast"); var options = new ChatOptions { - Tools = [new GrokSearchTool(GrokSearch.On) - { - ReturnCitations = false, - Sources = - [ - new GrokWebSource - { - AllowedWebsites = - [ - "https://catedralaltapatagonia.com", - "https://catedralaltapatagonia.com/parte-de-nieve/", - "https://catedralaltapatagonia.com/tarifas/" - ] - }, - ] + Tools = [new HostedMcpServerTool("GitHub", "https://api.githubcopilot.com/mcp/") { + AuthorizationToken = Configuration["GITHUB_TOKEN"]!, + AllowedTools = ["list_releases"], }] }; var response = await grok.GetResponseAsync(messages, options); var text = response.Text; - // assert that the request contains the following node - // "search_parameters": { - // "mode": "auto" - // "return_citations": "false" - //} - Assert.All(requests, x => + Assert.Equal("2025-11-29", text); + var call = Assert.Single(response.Messages + .SelectMany(x => x.Contents) + .OfType()); + + Assert.Equal("GitHub.list_releases", call.ToolName); + } + + [SecretsFact("XAI_API_KEY", "GITHUB_TOKEN")] + public async Task GrokInvokesHostedMcpWithOutput() + { + var messages = new Chat() + { + { "user", "When was GrokClient v1.0.0 released on the devlooped/GrokClient repo? Respond with just the date, in YYYY-MM-DD format." }, + }; + + var grok = new GrokClient(Configuration["XAI_API_KEY"]!).AsIChatClient("grok-4-fast"); + + var options = new GrokChatOptions + { + Include = { Devlooped.Grok.IncludeOption.McpCallOutput }, + Tools = [new HostedMcpServerTool("GitHub", "https://api.githubcopilot.com/mcp/") { + AuthorizationToken = Configuration["GITHUB_TOKEN"]!, + AllowedTools = ["list_releases"], + }] + }; + + var response = await grok.GetResponseAsync(messages, options); + + // Can include result of MCP tool + var output = Assert.Single(response.Messages + .SelectMany(x => x.Contents) + .OfType()); + + Assert.NotNull(output.Output); + Assert.Single(output.Output); + var json = Assert.Single(output.Output!.OfType()).Text; + var tags = JsonSerializer.Deserialize>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web) { - var search = Assert.IsType(x["search_parameters"]); - Assert.Equal("on", search["mode"]?.GetValue()); - Assert.False(search["return_citations"]?.GetValue()); + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }); - // Citations are not included - Assert.Single(responses); - var node = responses[0]; - Assert.NotNull(node); - Assert.Null(node["citations"]); + Assert.NotNull(tags); + Assert.Contains(tags, x => x.TagName == "v1.0.0"); + } + + record Release(string TagName, DateTimeOffset CreatedAt); + + [SecretsFact("XAI_API_KEY", "GITHUB_TOKEN")] + public async Task GrokStreamsUpdatesFromAllTools() + { + var messages = new Chat() + { + { "user", + """ + What's the oldest stable version released on the devlooped/GrokClient repo on GitHub?, + what is the current price of Tesla stock, + and what is the current date? Respond with the following JSON: + { + "today": "[get_date result]", + "release": "[first stable release of devlooped/GrokClient, using GitHub MCP tool]", + "price": [$TSLA price using web search tool] + } + """ + }, + }; + + var grok = new GrokClient(Configuration["XAI_API_KEY"]!) + .AsIChatClient("grok-4-fast") + .AsBuilder() + .UseFunctionInvocation() + .UseLogging(output.AsLoggerFactory()) + .Build(); + + var getDateCalls = 0; + var options = new GrokChatOptions + { + Include = { IncludeOption.McpCallOutput }, + Tools = + [ + new HostedWebSearchTool(), + new HostedMcpServerTool("GitHub", "https://api.githubcopilot.com/mcp/") { + AuthorizationToken = Configuration["GITHUB_TOKEN"]!, + AllowedTools = ["list_releases", "get_release_by_tag"], + }, + AIFunctionFactory.Create(() => { + getDateCalls++; + return DateTimeOffset.Now.ToString("O"); + }, "get_date", "Gets the current date") + ] + }; + + var updates = await grok.GetStreamingResponseAsync(messages, options).ToListAsync(); + var response = updates.ToChatResponse(); + var typed = JsonSerializer.Deserialize(response.Messages.Last().Text, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + Assert.NotNull(typed); + + Assert.NotEmpty(response.Messages + .SelectMany(x => x.Contents) + .OfType()); + + Assert.Contains(response.Messages + .SelectMany(x => x.Contents) + .OfType() + .Select(x => x.RawRepresentation as Devlooped.Grok.ToolCall), + x => x?.Type == Devlooped.Grok.ToolCallType.WebSearchTool); + + Assert.Equal(1, getDateCalls); + + Assert.Equal(DateOnly.FromDateTime(DateTime.Today), typed.Today); + Assert.EndsWith("1.0.0", typed.Release); + Assert.True(typed.Price > 100); } -} \ No newline at end of file + + record Response(DateOnly Today, string Release, decimal Price); +} diff --git a/src/Tests/RetrievalTests.cs b/src/Tests/RetrievalTests.cs index 57f87eb..e661353 100644 --- a/src/Tests/RetrievalTests.cs +++ b/src/Tests/RetrievalTests.cs @@ -6,7 +6,7 @@ namespace Devlooped.Extensions.AI; public class RetrievalTests(ITestOutputHelper output) { - [SecretsTheory("OPENAI_API_KEY")] + [SecretsTheory("OPENAI_API_KEY", Skip = "Vector processing not completing")] [InlineData("gpt-4.1-nano", "Qué es la rebeldía en el Código Procesal Civil y Comercial Nacional?")] [InlineData("gpt-4.1-nano", "What's the battery life in an iPhone 15?", true)] public async Task CanRetrieveContent(string model, string question, bool empty = false) @@ -18,7 +18,12 @@ public async Task CanRetrieveContent(string model, string question, bool empty = var file = client.GetOpenAIFileClient().UploadFile("Content/LNS0004592.md", global::OpenAI.Files.FileUploadPurpose.Assistants); try { - client.GetVectorStoreClient().AddFileToVectorStore(store.Value.Id, file.Value.Id); + var result = client.GetVectorStoreClient().AddFileToVectorStore(store.Value.Id, file.Value.Id); + while (result.Value.Status != global::OpenAI.VectorStores.VectorStoreFileStatus.Cancelled) + { + await Task.Delay(100); + result = client.GetVectorStoreClient().GetVectorStoreFile(store.Value.Id, file.Value.Id); + } var responses = new OpenAIResponseClient(model, Configuration["OPENAI_API_KEY"]); diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index aa4ee14..cefafa6 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -2,11 +2,10 @@ net10.0 - OPENAI001;$(NoWarn) + OPENAI001;MEAI001;DEAI001;$(NoWarn) Preview true Devlooped - 10.0.0-rc.* @@ -16,23 +15,25 @@ - - - - + + + + - - + + - - + + + +