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 @@
-
-
-
-
+
+
+
+
-
-
+
+
-
-
+
+
+
+