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
+[](https://www.nuget.org/packages/grok)
+[](https://www.nuget.org/packages/grok)
+
+
+Sample Grok CLI client based on the xAI
+
+
+
+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 @@
+[](osmfeula.txt)
+[](license.txt)
+[](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 @@
[](osmfeula.txt)
[](license.txt)
-[](https://github.com/devlooped/GrokClient)
+[](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 @@
[](osmfeula.txt)
[](license.txt)
-[](https://github.com/devlooped/AI)
+[](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 @@
+