diff --git a/.netconfig b/.netconfig index 4974e00..7f95eff 100644 --- a/.netconfig +++ b/.netconfig @@ -195,3 +195,8 @@ sha = 3012d56be7554c483e5c5d277144c063969cada9 etag = 43c81c6c6dcdf5baee40a9e3edc5e871e473e6c954c901b82bb87a3a48888ea0 weak +[file "src/Grok/Extensions/AIContentExtensions.cs"] + url = https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContentExtensions.cs + sha = c221abef4b4f1bf3fcf0bda27490e8b26bb479f4 + etag = 510868eaae58941d71cdcb5416f44ebf4436a22d342ca5838172127b04252fe0 + weak diff --git a/assets/img/cli.png b/assets/img/cli.png new file mode 100644 index 0000000..56bec70 Binary files /dev/null and b/assets/img/cli.png differ diff --git a/readme.md b/readme.md index e128fb7..8d9bfac 100644 --- a/readme.md +++ b/readme.md @@ -345,6 +345,17 @@ See for example the [introduction of tool output and citations](https://github.c +# CLI +[![Version](https://img.shields.io/nuget/vpre/grok.svg?color=royalblue)](https://www.nuget.org/packages/grok) +[![Downloads](https://img.shields.io/nuget/dt/grok.svg?color=green)](https://www.nuget.org/packages/grok) + + +Sample Grok CLI client based on the xAI + +![](https://raw.githubusercontent.com/devlooped/xAI/main/assets/img/cli.png) + +Uses your own API Key. + # Sponsors diff --git a/src/Directory.targets b/src/Directory.targets index 3eae117..2fb8627 100644 --- a/src/Directory.targets +++ b/src/Directory.targets @@ -1,4 +1,4 @@ - + diff --git a/src/Grok/Extensions/.editorconfig b/src/Grok/Extensions/.editorconfig new file mode 100644 index 0000000..e23a913 --- /dev/null +++ b/src/Grok/Extensions/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*.cs] +generated_code = true \ No newline at end of file diff --git a/src/Grok/Extensions/AIContentExtensions.cs b/src/Grok/Extensions/AIContentExtensions.cs new file mode 100644 index 0000000..550a48a --- /dev/null +++ b/src/Grok/Extensions/AIContentExtensions.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +#if NET +using System.Runtime.CompilerServices; +#else +using System.Text; +#endif + +namespace Microsoft.Extensions.AI; + +/// Internal extensions for working with . +internal static class AIContentExtensions +{ + /// Concatenates the text of all instances in the list. + public static string ConcatText(this IEnumerable contents) + { + if (contents is IList list) + { + int count = list.Count; + switch (count) + { + case 0: + return string.Empty; + + case 1: + return (list[0] as TextContent)?.Text ?? string.Empty; + + default: +#if NET + DefaultInterpolatedStringHandler builder = new(count, 0, null, stackalloc char[512]); + for (int i = 0; i < count; i++) + { + if (list[i] is TextContent text) + { + builder.AppendLiteral(text.Text); + } + } + + return builder.ToStringAndClear(); +#else + StringBuilder builder = new(); + for (int i = 0; i < count; i++) + { + if (list[i] is TextContent text) + { + builder.Append(text.Text); + } + } + + return builder.ToString(); +#endif + } + } + + return string.Concat(contents.OfType()); + } + + /// Concatenates the of all instances in the list. + /// A newline separator is added between each non-empty piece of text. + public static string ConcatText(this IList messages) + { + int count = messages.Count; + switch (count) + { + case 0: + return string.Empty; + + case 1: + return messages[0].Text; + + default: +#if NET + DefaultInterpolatedStringHandler builder = new(count, 0, null, stackalloc char[512]); + bool needsSeparator = false; + for (int i = 0; i < count; i++) + { + string text = messages[i].Text; + if (text.Length > 0) + { + if (needsSeparator) + { + builder.AppendLiteral(Environment.NewLine); + } + + builder.AppendLiteral(text); + + needsSeparator = true; + } + } + + return builder.ToStringAndClear(); +#else + StringBuilder builder = new(); + for (int i = 0; i < count; i++) + { + string text = messages[i].Text; + if (text.Length > 0) + { + if (builder.Length > 0) + { + builder.AppendLine(); + } + + builder.Append(text); + } + } + + return builder.ToString(); +#endif + } + } +} diff --git a/src/Grok/Grok.csproj b/src/Grok/Grok.csproj new file mode 100644 index 0000000..6fda30b --- /dev/null +++ b/src/Grok/Grok.csproj @@ -0,0 +1,47 @@ + + + + Exe + net10.0 + grok + Sample Grok CLI using xAI and xAI.Protocol packages + false + grok + dotnet-tool xai ai grok llm + true + true + MEAI001;xAI001;$(NoWarn) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Grok/Interactive.cs b/src/Grok/Interactive.cs new file mode 100644 index 0000000..28c756f --- /dev/null +++ b/src/Grok/Interactive.cs @@ -0,0 +1,241 @@ +using System.Diagnostics; +using System.Text.Json; +using DotNetConfig; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Spectre.Console; +using Spectre.Console.Json; +using xAI; +using xAI.Protocol; +using static Spectre.Console.AnsiConsole; + +namespace Grok; + +partial class Interactive(IConfiguration configuration) : IHostedService +{ + readonly CancellationTokenSource cts = new(); + string? apiKey = configuration["grok:apikey"]; + GrokClient? client; + + public Task StartAsync(CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(apiKey)) + { + apiKey = Ask("Enter Grok API key:"); + Config.Build(ConfigLevel.Global).SetString("grok", "apikey", apiKey); + } + + client = new GrokClient(apiKey); + + _ = Task.Run(InputListener, cancellationToken); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + cts.Cancel(); + MarkupLine($":robot: Stopping"); + return Task.CompletedTask; + } + + async Task InputListener() + { + Debug.Assert(client != null); + + var models = await client.GetModelsClient().ListLanguageModelsAsync(); + var modelId = configuration["grok:modelid"]; + var choices = models.Select(x => x.Aliases.OrderBy(a => a.Length).FirstOrDefault() ?? x.Name).ToList(); + if (modelId != null && choices.IndexOf(modelId) is var index && index > 0) + { + choices.RemoveAt(index); + choices.Insert(0, modelId); + } + + modelId = Prompt(new SelectionPrompt().Title("Select model").AddChoices(choices)); + Config.Build(ConfigLevel.Global).SetString("grok", "modelid", modelId); + + var chat = client!.GetChatClient().AsIChatClient(modelId); + var options = new GrokChatOptions + { + Instructions = "Reply in the language, style and tone used by the user.", + Include = + [ + IncludeOption.CodeExecutionCallOutput, + IncludeOption.XSearchCallOutput, + IncludeOption.WebSearchCallOutput, + ], + Tools = [new GrokXSearchTool(), new HostedWebSearchTool(), new HostedCodeInterpreterTool()] + }; + var conversation = new List(); + + MarkupLine($":robot: Ready"); + Markup($":person_beard: "); + var green = new Style(Color.Lime); + var red = new Style(Color.Red); + var yellow = new Style(Color.Yellow); + + while (!cts.IsCancellationRequested) + { + var input = ReadInput(cts.Token); + if (!string.IsNullOrWhiteSpace(input)) + { + try + { + if (input is "cls" or "clear") + { + System.Console.Clear(); + conversation.Clear(); + } + else + { + conversation.Add(new ChatMessage(ChatRole.User, input)); + var response = new ChatMessage(ChatRole.Assistant, default(string?)); + await foreach (var update in chat.GetStreamingResponseAsync(conversation, options, cts.Token)) + { + foreach (var content in update.Contents) + { + var grid = new Grid() + .AddColumn(new GridColumn().Width(2).Padding(0, 0)) + .AddColumn(new GridColumn().Padding(1, 0)) + .AddColumn(new GridColumn().Padding(1, 0)); + + if (content.RawRepresentation is not ToolCall tool) + continue; + + if (content is CodeInterpreterToolResultContent codeResult) + { + grid.AddRow(new Markup(":desktop_computer:"), new Markup($" {tool.Function.Name} :check_mark:")); + if (codeResult.Outputs?.ConcatText() is { } output) + { + if (output.StartsWith('{') && output.EndsWith('}') && + JsonElement.Parse(output) is var json && + json.TryGetProperty("stdout", out var stdOut) && + json.TryGetProperty("stderr", out var stdErr)) + { + if (stdOut.GetString()?.Trim() is { Length: > 0 } outText) + grid.AddRow(new Text(" "), new Panel(new Paragraph(outText, green)) + .Border(BoxBorder.Square)); + if (stdErr.GetString()?.Trim() is { Length: > 0 } errText) + grid.AddRow(new Text(" "), new Panel(new Paragraph(errText, red)) + .Border(BoxBorder.Square)); + } + else + { + grid.AddRow(new Text(" "), new Panel(new Paragraph(output, green)) + .Border(BoxBorder.Square)); + } + } + Write(grid); + continue; + } + + if (tool.Function.Arguments.StartsWith('{') && tool.Function.Arguments.EndsWith('}')) + { + var json = JsonElement.Parse(tool.Function.Arguments); + if (tool.Type == ToolCallType.WebSearchTool && + json.TryGetProperty("query", out var query)) + { + if (tool.Status != ToolCallStatus.Completed) + { + grid.AddRow(new Markup(":magnifying_glass_tilted_right:"), new Text(tool.Function.Name), + new Text(query.GetString() ?? " ", yellow)); + } + } + else if (content is CodeInterpreterToolCallContent && + json.TryGetProperty("code", out var code)) + { + // We don't want this tool content case to fall back below unless it's pending. + if (tool.Status != ToolCallStatus.Completed) + { + grid.AddRow(new Markup(":desktop_computer:"), new Markup($" {tool.Function.Name} :hourglass_not_done:")); + grid.AddRow(new Text(" "), new Panel(new Paragraph(code.GetString()?.Trim() ?? "", green)) + .Border(BoxBorder.Square)); + } + } + else if (tool.Function.Name == "browse_page" && + json.TryGetProperty("url", out var url)) + { + if (tool.Status != ToolCallStatus.Completed) + { + var link = url.GetString() ?? ""; + grid.AddRow(new Markup(":globe_with_meridians:"), new Text(tool.Function.Name), + new Text(link, new Style(Color.Blue, link: link))); + } + } + else + { + grid.AddRow(new Markup(":hammer_and_pick:"), new Text(tool.Function.Name)); + grid.AddRow(new Text(""), new JsonText(tool.Function.Arguments)); + } + } + + Write(grid); + } + + foreach (var thinking in update.Contents.OfType()) + MarkupLineInterpolated($":brain: {thinking.Text}"); + foreach (var content in update.Contents.OfType()) + System.Console.Write(content.Text); + + foreach (var content in update.Contents) + response.Contents.Add(content); + } + WriteLine(); + conversation.Add(response); + } + } + catch (Exception e) + { + WriteException(e); + } + finally + { + Markup($":person_beard: "); + } + } + else + { + Markup($":person_beard: "); + } + } + } + + static string ReadInput(CancellationToken cancellation) + { + var sb = new System.Text.StringBuilder(); + while (!cancellation.IsCancellationRequested) + { + var key = System.Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) + { + if (key.Modifiers.HasFlag(ConsoleModifiers.Shift)) + { + sb.Append(Environment.NewLine); + System.Console.WriteLine(); + } + else + { + System.Console.WriteLine(); + break; + } + } + else if (key.Key == ConsoleKey.Backspace) + { + if (sb.Length > 0) + { + sb.Length--; + System.Console.Write("\b \b"); + } + } + else if (!char.IsControl(key.KeyChar)) + { + sb.Append(key.KeyChar); + System.Console.Write(key.KeyChar); + } + } + + return sb.ToString().Trim(); + } +} diff --git a/src/Grok/Program.cs b/src/Grok/Program.cs new file mode 100644 index 0000000..85e632b --- /dev/null +++ b/src/Grok/Program.cs @@ -0,0 +1,36 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using Grok; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +// Some users reported not getting emoji on Windows from F5 in VS so we force UTF-8 encoding. +if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + Console.InputEncoding = Console.OutputEncoding = Encoding.UTF8; + +var host = Host.CreateApplicationBuilder(args); +host.Logging.ClearProviders(); + +host.Configuration.AddDotNetConfig(); +host.Configuration.AddUserSecrets(); + +host.Services.AddHttpClient(); +host.Services.ConfigureHttpClientDefaults(x => +{ + if (Debugger.IsAttached) + x.ConfigureHttpClient(h => h.Timeout = TimeSpan.MaxValue); + else + x.AddStandardResilienceHandler(); +}); + +var cts = new CancellationTokenSource(); +Console.CancelKeyPress += (s, e) => cts.Cancel(); +host.Services.AddSingleton(cts); +host.Services.AddSingleton(); + +var app = host.Build(); + +await app.RunAsync(cts.Token); diff --git a/src/Grok/readme.md b/src/Grok/readme.md new file mode 100644 index 0000000..d1b6ad1 --- /dev/null +++ b/src/Grok/readme.md @@ -0,0 +1,7 @@ +[![EULA](https://img.shields.io/badge/EULA-OSMF-blue?labelColor=black&color=C9FF30)](osmfeula.txt) +[![OSS](https://img.shields.io/github/license/devlooped/oss.svg?color=blue)](license.txt) +[![GitHub](https://img.shields.io/badge/-source-181717.svg?logo=GitHub)](https://github.com/devlooped/xAI) + + + + \ No newline at end of file diff --git a/src/xAI.Protocol/readme.md b/src/xAI.Protocol/readme.md index 7dd9088..f725eaf 100644 --- a/src/xAI.Protocol/readme.md +++ b/src/xAI.Protocol/readme.md @@ -1,6 +1,6 @@ [![EULA](https://img.shields.io/badge/EULA-OSMF-blue?labelColor=black&color=C9FF30)](osmfeula.txt) [![OSS](https://img.shields.io/github/license/devlooped/oss.svg?color=blue)](license.txt) -[![GitHub](https://img.shields.io/badge/-source-181717.svg?logo=GitHub)](https://github.com/devlooped/GrokClient) +[![GitHub](https://img.shields.io/badge/-source-181717.svg?logo=GitHub)](https://github.com/devlooped/xAI) Grok client based on the official gRPC API reference from xAI diff --git a/src/xAI.Tests/ChatClientTests.cs b/src/xAI.Tests/ChatClientTests.cs index cf3b3ab..3e537cf 100644 --- a/src/xAI.Tests/ChatClientTests.cs +++ b/src/xAI.Tests/ChatClientTests.cs @@ -514,5 +514,11 @@ public async Task GrokCustomFactoryInvokedFromOptions() Assert.Equal("Hey Cazzulino!", response.Text); } + [Fact] + public async Task AskFiles() + { + + } + record Response(DateOnly Today, string Release, decimal Price); } diff --git a/src/xAI.Tests/SanityChecks.cs b/src/xAI.Tests/SanityChecks.cs index aaf3ad0..f5bda92 100644 --- a/src/xAI.Tests/SanityChecks.cs +++ b/src/xAI.Tests/SanityChecks.cs @@ -185,18 +185,19 @@ public async Task IntegrationTestStreaming() { "system", "You are a helpful assistant that uses all available tools to answer questions accurately." }, { "user", $$""" - Current timestamp is {{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}}. - Please answer the following questions using the appropriate tools: 1. What is today's date? (use get_date tool) - 2. What is the current price of Tesla (TSLA) stock? (use Yahoo news web search) - 3. Calculate the earnings that would be produced by compound interest to $5k at 4% annually for 5 years (use code interpreter) - 4. What is the latest release version of the {{ThisAssembly.Git.Url}} repository? (use GitHub MCP tool) + 2. What is the current price of Tesla (TSLA) stock? (use Yahoo news web search, always include citations) + 3. What is the top news from Tesla on X? + 4. Calculate the earnings that would be produced by compound interest to $5k savings at 4% annually for 5 years (use code interpreter). + Return just the earnings, not the grand total of savings plus earnings). + 5. What is the latest release version of the {{ThisAssembly.Git.Url}} repository? (use GitHub MCP tool) Respond with a JSON object in this exact format: { "today": "[date from get_date in YYYY-MM-DD format]", "tesla_price": [numeric price from web search], + "tesla_news": "[top news from X]", "compound_interest": [numeric result from code interpreter], "latest_release": "[version string from GitHub]" } @@ -215,33 +216,32 @@ 2. What is the current price of Tesla (TSLA) stock? (use Yahoo news web search) var options = new GrokChatOptions { + ResponseFormat = ChatResponseFormat.Json, Include = [ IncludeOption.InlineCitations, IncludeOption.WebSearchCallOutput, IncludeOption.CodeExecutionCallOutput, - IncludeOption.McpCallOutput + IncludeOption.McpCallOutput, + IncludeOption.XSearchCallOutput, ], Tools = [ - // Client-side tool AIFunctionFactory.Create(() => { getDateCalls++; return DateTime.Now.ToString("yyyy-MM-dd"); }, "get_date", "Gets the current date in YYYY-MM-DD format"), - - // Hosted web search tool new HostedWebSearchTool(), - - // Hosted code interpreter tool new HostedCodeInterpreterTool(), - - // Hosted MCP server tool (GitHub) new HostedMcpServerTool("GitHub", "https://api.githubcopilot.com/mcp/") { AuthorizationToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN")!, AllowedTools = ["list_releases", "get_release_by_tag"], + }, + new GrokXSearchTool + { + AllowedHandles = ["tesla"] } ] }; @@ -339,6 +339,10 @@ void AssertIntegrationTest(ChatResponse response, Func getDateCalls) output.WriteLine($"Parsed response: Today={result.Today}, TeslaPrice={result.TeslaPrice}, CompoundInterest={result.CompoundInterest}, LatestRelease={result.LatestRelease}"); } + else + { + Assert.Fail("Response did not contain expected JSON output"); + } output.WriteLine($"Code interpreter calls: {codeInterpreterCalls.Count}"); output.WriteLine($"MCP calls: {mcpCalls.Count}"); @@ -347,6 +351,7 @@ void AssertIntegrationTest(ChatResponse response, Func getDateCalls) record IntegrationTestResponse( string Today, decimal TeslaPrice, + string TeslaNews, decimal CompoundInterest, string LatestRelease); } diff --git a/src/xAI/GrokChatClient.cs b/src/xAI/GrokChatClient.cs index 04694e1..171c5d1 100644 --- a/src/xAI/GrokChatClient.cs +++ b/src/xAI/GrokChatClient.cs @@ -71,8 +71,9 @@ async IAsyncEnumerable CompleteChatStreamingCore(IEnumerable 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) + var update = new ChatResponseUpdate { + Role = MapRole(output.Delta.Role), ResponseId = chunk.Id, ModelId = chunk.Model, CreatedAt = chunk.Created?.ToDateTimeOffset(), @@ -94,6 +95,12 @@ async IAsyncEnumerable CompleteChatStreamingCore(IEnumerable ((List)update.Contents).AddRange(output.Delta.ToolCalls.AsContents(text, citations)); + // Only append text content if it's not already part of other tools' content + if (!update.Contents.OfType().Any() && + !update.Contents.OfType().Any() && + text is not null) + update.Contents.Add(new TextContent(text)); + if (MapToUsage(chunk.Usage) is { } usage) update.Contents.Add(new UsageContent(usage) { RawRepresentation = chunk.Usage }); @@ -279,8 +286,6 @@ codeResult.RawRepresentation is ToolCall codeToolCall && { InputTokenCount = usage.PromptTokens, OutputTokenCount = usage.CompletionTokens, - CachedInputTokenCount = usage.CachedPromptTextTokens, - ReasoningTokenCount = usage.ReasoningTokens, TotalTokenCount = usage.TotalTokens }; diff --git a/src/xAI/GrokProtocolExtensions.cs b/src/xAI/GrokProtocolExtensions.cs index 2aaabd8..e6dd3b3 100644 --- a/src/xAI/GrokProtocolExtensions.cs +++ b/src/xAI/GrokProtocolExtensions.cs @@ -1,15 +1,9 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; +using System.ComponentModel; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; -using System.Threading.Tasks; -using Google.Protobuf; using Microsoft.Extensions.AI; using xAI.Protocol; -using static Google.Protobuf.Reflection.GeneratedCodeInfo.Types; namespace xAI; diff --git a/src/xAI/readme.md b/src/xAI/readme.md index 14c1dec..cb81a41 100644 --- a/src/xAI/readme.md +++ b/src/xAI/readme.md @@ -1,6 +1,6 @@ [![EULA](https://img.shields.io/badge/EULA-OSMF-blue?labelColor=black&color=C9FF30)](osmfeula.txt) [![OSS](https://img.shields.io/github/license/devlooped/oss.svg?color=blue)](license.txt) -[![GitHub](https://img.shields.io/badge/-source-181717.svg?logo=GitHub)](https://github.com/devlooped/AI) +[![GitHub](https://img.shields.io/badge/-source-181717.svg?logo=GitHub)](https://github.com/devlooped/xAI) diff --git a/src/xAI/xAI.csproj b/src/xAI/xAI.csproj index 2832cad..04a97c3 100644 --- a/src/xAI/xAI.csproj +++ b/src/xAI/xAI.csproj @@ -4,7 +4,8 @@ net8.0;net10.0 xAI xAI - xAI Grok integration for Microsoft.Extensions.AI + xAI / Grok integration for Microsoft.Extensions.AI + xai ai grok llm OSMFEULA.txt true @@ -29,4 +30,4 @@ - \ No newline at end of file + diff --git a/xAI.slnx b/xAI.slnx index 5a3b5a0..4b07ded 100644 --- a/xAI.slnx +++ b/xAI.slnx @@ -1,4 +1,5 @@ +