diff --git a/.netconfig b/.netconfig index c387778..ed8619a 100644 --- a/.netconfig +++ b/.netconfig @@ -96,9 +96,7 @@ [file "Directory.Build.rsp"] url = https://github.com/devlooped/oss/blob/main/Directory.Build.rsp sha = 0f7f7f7e8a29de9b535676f75fe7c67e629a5e8c - - etag = 0ccae83fc51f400bfd7058170bfec7aba11455e24a46a0d7e6a358da6486e255 - weak + skip [file "_config.yml"] url = https://github.com/devlooped/oss/blob/main/_config.yml sha = 68b409c486842062e0de0e5b11e6fdb7cd12d6e2 diff --git a/Directory.Build.rsp b/Directory.Build.rsp deleted file mode 100644 index 509cc66..0000000 --- a/Directory.Build.rsp +++ /dev/null @@ -1,5 +0,0 @@ -# See https://docs.microsoft.com/en-us/visualstudio/msbuild/msbuild-response-files --nr:false --m:1 --v:m --clp:Summary;ForceNoAlign diff --git a/src/AI.Tests/GrokTests.cs b/src/AI.Tests/GrokTests.cs new file mode 100644 index 0000000..6717970 --- /dev/null +++ b/src/AI.Tests/GrokTests.cs @@ -0,0 +1,94 @@ +namespace Devlooped.Extensions.AI; + +using Microsoft.Extensions.AI; +using static ConfigurationExtensions; + +public class GrokTests +{ + [SecretsFact("XAI_API_KEY")] + public async Task GrokInvokesTools() + { + var messages = new Chat() + { + { "system", "You are a bot that invokes the tool get_date when asked for the date." }, + { "user", "What day is today?" }, + }; + + var grok = new GrokClient(Configuration["XAI_API_KEY"]!); + + var options = new GrokChatOptions + { + ModelId = "grok-3-mini", + Search = GrokSearch.Auto, + Tools = [AIFunctionFactory.Create(() => DateTimeOffset.Now.ToString("O"), "get_date")] + }; + + var response = await grok.GetResponseAsync(messages, options); + var getdate = response.Messages + .SelectMany(x => x.Contents.OfType()) + .Any(x => x.Name == "get_date"); + + Assert.True(getdate); + } + + [SecretsFact("XAI_API_KEY")] + 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." }, + { "user", "What's Tesla stock worth today?" }, + }; + + var grok = new GrokClient(Configuration["XAI_API_KEY"]!) + .AsBuilder() + .UseFunctionInvocation() + .Build(); + + var options = new GrokChatOptions + { + ModelId = "grok-3-mini", + Search = GrokSearch.On, + Tools = [AIFunctionFactory.Create(() => DateTimeOffset.Now.ToString("O"), "get_date")] + }; + + var response = await grok.GetResponseAsync(messages, options); + + // The get_date result shows up as a tool role + Assert.Contains(response.Messages, x => x.Role == ChatRole.Tool); + + var text = response.Text; + + Assert.Contains("TSLA", text); + Assert.Contains("$", text); + Assert.Contains("Nasdaq", text, StringComparison.OrdinalIgnoreCase); + } + + [SecretsFact("XAI_API_KEY")] + public async Task GrokThinksHard() + { + 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?" }, + }; + + var grok = new GrokClient(Configuration["XAI_API_KEY"]!) + .AsBuilder() + .UseFunctionInvocation() + .Build(); + + var options = new GrokChatOptions + { + ModelId = "grok-3-mini", + Search = GrokSearch.Off, + ReasoningEffort = ReasoningEffort.High, + }; + + var response = await grok.GetResponseAsync(messages, options); + + var text = response.Text; + + Assert.Contains("48 years", text); + } +} diff --git a/src/AI/ChatExtensions.cs b/src/AI/ChatExtensions.cs index 6e0d14a..d906509 100644 --- a/src/AI/ChatExtensions.cs +++ b/src/AI/ChatExtensions.cs @@ -20,4 +20,4 @@ public static class ChatExtensions public Task GetResponseAsync(Chat chat, ChatOptions? options = null, CancellationToken cancellation = default) => client.GetResponseAsync((IEnumerable)chat, options, cancellation); } -} +} \ No newline at end of file diff --git a/src/AI/Grok/GrokChatOptions.cs b/src/AI/Grok/GrokChatOptions.cs new file mode 100644 index 0000000..648c050 --- /dev/null +++ b/src/AI/Grok/GrokChatOptions.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.AI; + +namespace Devlooped.Extensions.AI; + +/// +/// 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. + /// + 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/AI/Grok/GrokClient.cs b/src/AI/Grok/GrokClient.cs new file mode 100644 index 0000000..0e6be9e --- /dev/null +++ b/src/AI/Grok/GrokClient.cs @@ -0,0 +1,83 @@ +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.Extensions.AI; +using OpenAI; + +namespace Devlooped.Extensions.AI; + +public class GrokClient(string apiKey, GrokClientOptions options) + : OpenAIClient(new ApiKeyCredential(apiKey), options), IChatClient +{ + readonly GrokClientOptions clientOptions = options; + readonly ConcurrentDictionary clients = new(); + + public GrokClient(string apiKey) + : this(apiKey, new()) + { + } + + void IDisposable.Dispose() { } + object? IChatClient.GetService(Type serviceType, object? serviceKey) => default; + + Task IChatClient.GetResponseAsync(IEnumerable messages, ChatOptions? options, CancellationToken cancellation) + => GetClient(options).GetResponseAsync(messages, SetOptions(options), cancellation); + + IAsyncEnumerable IChatClient.GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options, CancellationToken cancellation) + => GetClient(options).GetStreamingResponseAsync(messages, SetOptions(options), cancellation); + + IChatClient GetClient(ChatOptions? options) => clients.GetOrAdd( + options?.ModelId ?? clientOptions.Model, + model => base.GetChatClient(model).AsIChatClient()); + + ChatOptions? SetOptions(ChatOptions? options) + { + if (options is null || options is not GrokChatOptions grok) + return null; + + options.RawRepresentationFactory = _ => + { + var result = new GrokCompletionOptions + { + Search = grok.Search + }; + + if (grok.ReasoningEffort != null) + { + result.ReasoningEffortLevel = grok.ReasoningEffort switch + { + ReasoningEffort.Low => OpenAI.Chat.ChatReasoningEffortLevel.Low, + ReasoningEffort.High => OpenAI.Chat.ChatReasoningEffortLevel.High, + _ => throw new ArgumentException($"Unsupported reasoning effort {grok.ReasoningEffort}") + }; + } + + return result; + }; + + return options; + } + + class SearchParameters + { + public GrokSearch Mode { get; set; } = GrokSearch.Auto; + } + + class GrokCompletionOptions : OpenAI.Chat.ChatCompletionOptions + { + public GrokSearch Search { get; set; } = GrokSearch.Auto; + + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + base.JsonModelWriteCore(writer, options); + + // "search_parameters": { "mode": "auto" } + writer.WritePropertyName("search_parameters"); + writer.WriteStartObject(); + writer.WriteString("mode", Search.ToString().ToLowerInvariant()); + writer.WriteEndObject(); + } + } +} + diff --git a/src/AI/Grok/GrokClientOptions.cs b/src/AI/Grok/GrokClientOptions.cs new file mode 100644 index 0000000..8943d36 --- /dev/null +++ b/src/AI/Grok/GrokClientOptions.cs @@ -0,0 +1,16 @@ +using OpenAI; + +namespace Devlooped.Extensions.AI; + +public class GrokClientOptions : OpenAIClientOptions +{ + public GrokClientOptions() : this("grok-3") { } + + public GrokClientOptions(string model) + { + Endpoint = new Uri("https://api.x.ai/v1"); + Model = Throw.IfNullOrEmpty(model); + } + + public string Model { get; } +} diff --git a/src/AI/Grok/GrokSearch.cs b/src/AI/Grok/GrokSearch.cs new file mode 100644 index 0000000..552ddcd --- /dev/null +++ b/src/AI/Grok/GrokSearch.cs @@ -0,0 +1,3 @@ +namespace Devlooped.Extensions.AI; + +public enum GrokSearch { Auto, On, Off } diff --git a/src/AI/Grok/GrokServiceCollectionExtensions.cs b/src/AI/Grok/GrokServiceCollectionExtensions.cs new file mode 100644 index 0000000..9352b8f --- /dev/null +++ b/src/AI/Grok/GrokServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using System.ComponentModel; +using Devlooped.Extensions.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Devlooped.Extensions.AI; + +/// +/// Extensions for registering the as a chat client in the service collection. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class GrokServiceCollectionExtensions +{ + extension(IServiceCollection services) + { + /// + /// Registers the as a chat client in the service collection. + /// + /// The factory to create the Grok client. + /// The optional service lifetime. + /// The to further build the pipeline. + public ChatClientBuilder AddGrok(Func factory, ServiceLifetime lifetime = ServiceLifetime.Singleton) + => services.AddChatClient(services + => factory(services.GetRequiredService()), lifetime); + } +} diff --git a/src/AI/ReasoningEffort.cs b/src/AI/ReasoningEffort.cs new file mode 100644 index 0000000..9583bc0 --- /dev/null +++ b/src/AI/ReasoningEffort.cs @@ -0,0 +1,6 @@ +namespace Devlooped.Extensions.AI; + +/// +/// Reasoning effort an AI should apply when generating a response. +/// +public enum ReasoningEffort { Low, High } \ No newline at end of file diff --git a/src/Directory.targets b/src/Directory.targets index a790e87..fc71061 100644 --- a/src/Directory.targets +++ b/src/Directory.targets @@ -1,5 +1,9 @@ + + true + +