From 1197fcd6ea8f44c0c8f021ba252ef049493f8526 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Wed, 8 Apr 2026 14:55:53 -0300 Subject: [PATCH] Add support for inline data and url file attachements See also #105 --- src/xAI.Tests/ChatClientTests.cs | 48 +++++--- src/xAI.Tests/preferences.pdf | Bin 0 -> 27604 bytes src/xAI.Tests/xAI.Tests.csproj | 1 + src/xAI/GrokChatClient.cs | 174 ++------------------------- src/xAI/GrokProtocolExtensions.cs | 189 ++++++++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 180 deletions(-) create mode 100644 src/xAI.Tests/preferences.pdf diff --git a/src/xAI.Tests/ChatClientTests.cs b/src/xAI.Tests/ChatClientTests.cs index 93daa6d..df0a1d9 100644 --- a/src/xAI.Tests/ChatClientTests.cs +++ b/src/xAI.Tests/ChatClientTests.cs @@ -1,20 +1,13 @@ using System.Text.Json; -using System.Text.Json.Nodes; -using Azure; using Devlooped.Extensions.AI; -using Google.Protobuf; using Grpc.Core; -using Grpc.Core.Interceptors; -using Grpc.Net.Client; using Microsoft.Extensions.AI; using Moq; using OpenAI; using Tests.Client.Helpers; -using xAI; using xAI.Protocol; using static ConfigurationExtensions; using Chat = Devlooped.Extensions.AI.Chat; -using OpenAIClientOptions = OpenAI.OpenAIClientOptions; namespace xAI.Tests; @@ -219,6 +212,23 @@ public async Task GrokInvokesSpecificSearchUrl() Assert.Contains("catedralaltapatagonia.com", citations); } + [SecretsFact("XAI_API_KEY")] + public async Task GrokPerformsInlineFileSearch() + { + var grok = new GrokClient(Configuration["XAI_API_KEY"]!).AsIChatClient("grok-4-1-fast-non-reasoning"); + + var message = new ChatMessage(ChatRole.User, + [ + new DataContent(File.ReadAllBytes("preferences.pdf"), "application/pdf"), + new TextContent("what's my favorite company?") + ]); + + var response = await grok.GetResponseAsync(message); + var text = response.Text; + + Assert.Contains("SpaceX", text); + } + [SecretsFact("XAI_API_KEY")] public async Task GrokInvokesHostedSearchTool() { @@ -742,7 +752,7 @@ public async Task GrokDoesNotAddEmptyContentToToolCallOnlyMessages() } [Fact] - public async Task GrokSendsDataContentAsBase64ImageUrl() + public async Task GrokSendsUriContentAsImageUrl() { GetCompletionsRequest? capturedRequest = null; var client = new Mock(MockBehavior.Strict); @@ -759,11 +769,11 @@ public async Task GrokSendsDataContentAsBase64ImageUrl() } })); - var imageBytes = new byte[] { 1, 2, 3, 4, 5 }; + var imageUri = new Uri("https://example.com/photo.jpg"); var grok = new GrokChatClient(client.Object, "grok-4-1-fast-non-reasoning"); var messages = new List { - new(ChatRole.User, [new TextContent("What do you see?"), new DataContent(imageBytes, "image/png")]), + new(ChatRole.User, [new TextContent("What do you see?"), new UriContent(imageUri, "image/jpeg")]), }; await grok.GetResponseAsync(messages); @@ -775,11 +785,11 @@ public async Task GrokSendsDataContentAsBase64ImageUrl() Assert.Equal("What do you see?", userMessage.Content[0].Text); var imageContent = userMessage.Content[1].ImageUrl; Assert.NotNull(imageContent); - Assert.Equal($"data:image/png;base64,{Convert.ToBase64String(imageBytes)}", imageContent.ImageUrl); + Assert.Equal(imageUri.ToString(), imageContent.ImageUrl); } [Fact] - public async Task GrokSendsUriContentAsImageUrl() + public async Task GrokSendsDataUriContentAsData() { GetCompletionsRequest? capturedRequest = null; var client = new Mock(MockBehavior.Strict); @@ -791,16 +801,16 @@ public async Task GrokSendsUriContentAsImageUrl() { new CompletionOutput { - Message = new CompletionMessage { Content = "I see an image." } + Message = new CompletionMessage { Content = "I see a PDF." } } } })); - var imageUri = new Uri("https://example.com/photo.jpg"); + var pdfUri = new Uri("https://example.com/data.pdf"); var grok = new GrokChatClient(client.Object, "grok-4-1-fast-non-reasoning"); var messages = new List { - new(ChatRole.User, [new TextContent("What do you see?"), new UriContent(imageUri, "image/jpeg")]), + new(ChatRole.User, [new TextContent("What do you see?"), new UriContent(pdfUri, "application/pdf")]), }; await grok.GetResponseAsync(messages); @@ -810,9 +820,11 @@ public async Task GrokSendsUriContentAsImageUrl() Assert.NotNull(userMessage); Assert.Equal(2, userMessage.Content.Count); Assert.Equal("What do you see?", userMessage.Content[0].Text); - var imageContent = userMessage.Content[1].ImageUrl; - Assert.NotNull(imageContent); - Assert.Equal(imageUri.ToString(), imageContent.ImageUrl); + var pdfFile = userMessage.Content[1].File; + Assert.NotNull(pdfFile); + + Assert.Equal(pdfUri.ToString(), pdfFile.Url); + Assert.Equal("application/pdf", pdfFile.MimeType); } [Fact] diff --git a/src/xAI.Tests/preferences.pdf b/src/xAI.Tests/preferences.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4c9bfee2dd203be68fd8c36f45774f655c8c822b GIT binary patch literal 27604 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 + diff --git a/src/xAI/GrokChatClient.cs b/src/xAI/GrokChatClient.cs index eea6bdf..8763ae2 100644 --- a/src/xAI/GrokChatClient.cs +++ b/src/xAI/GrokChatClient.cs @@ -1,14 +1,17 @@ -using System.Text.Json; -using Google.Protobuf; using Grpc.Core; -using Grpc.Net.Client; using Microsoft.Extensions.AI; using xAI.Protocol; using static xAI.Protocol.Chat; namespace xAI; -class GrokChatClient : IChatClient +interface IGrokChatClient : IChatClient +{ + string DefaultModelId { get; } + string? EndUserId { get; } +} + +class GrokChatClient : IGrokChatClient { readonly ChatClientMetadata metadata; readonly ChatClient client; @@ -34,9 +37,12 @@ internal GrokChatClient(ChatClient client, GrokClientOptions clientOptions, stri metadata = new ChatClientMetadata("xai", clientOptions.Endpoint, defaultModelId); } + public string DefaultModelId => defaultModelId; + public string? EndUserId => clientOptions.EndUserId; + public async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { - var request = MapToRequest(messages, options); + var request = this.AsCompletionsRequest(messages, options); var response = await client.GetCompletionAsync(request, cancellationToken: cancellationToken); var lastOutput = response.Outputs.OrderByDescending(x => x.Index).FirstOrDefault(); @@ -62,7 +68,7 @@ public IAsyncEnumerable GetStreamingResponseAsync(IEnumerabl async IAsyncEnumerable CompleteChatStreamingCore(IEnumerable messages, ChatOptions? options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { - var request = MapToRequest(messages, options); + var request = this.AsCompletionsRequest(messages, options); var call = client.GetCompletionChunk(request, cancellationToken: cancellationToken); await foreach (var chunk in call.ResponseStream.ReadAllAsync(cancellationToken)) @@ -130,162 +136,6 @@ static CitationAnnotation MapCitation(string citation) }; } - GetCompletionsRequest MapToRequest(IEnumerable messages, ChatOptions? options) - { - var request = options?.RawRepresentationFactory?.Invoke(this) as GetCompletionsRequest ?? new GetCompletionsRequest() - { - Model = options?.ModelId ?? defaultModelId, - }; - - if (string.IsNullOrEmpty(request.Model)) - request.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) - { - if (message.RawRepresentation is Message input) - { - request.Messages.Add(input); - continue; - } - else if (message.RawRepresentation is CompletionMessage completion) - { - request.Messages.Add(completion.AsMessage()); - continue; - } - - var gmsg = new Message { Role = message.Role.Convert() }; - - foreach (var content in message.Contents) - { - if (content.RawRepresentation is CompletionMessage completion) - { - request.Messages.Add(completion.AsMessage()); - continue; - } - - if (content is TextContent textContent && !string.IsNullOrEmpty(textContent.Text)) - { - gmsg.Content.Add(new Content { Text = textContent.Text }); - } - else if (content is TextReasoningContent reasoning) - { - gmsg.ReasoningContent = reasoning.Text; - gmsg.EncryptedContent = reasoning.ProtectedData; - } - else if (content is DataContent dataContent && dataContent.HasTopLevelMediaType("image")) - { - gmsg.Content.Add(new Content { ImageUrl = new ImageUrlContent { ImageUrl = $"data:{dataContent.MediaType};base64,{Convert.ToBase64String(dataContent.Data.Span)}" } }); - } - else if (content is UriContent uriContent && uriContent.HasTopLevelMediaType("image")) - { - gmsg.Content.Add(new Content { ImageUrl = new ImageUrlContent { ImageUrl = uriContent.Uri.ToString() } }); - } - 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) - { - var msg = new Message - { - Role = MessageRole.RoleTool, - Content = { new Content { Text = JsonSerializer.Serialize(resultContent.Result) ?? "null" } } - }; - - if (resultContent.CallId is { Length: > 0 } callId) - msg.ToolCallId = callId; - - request.Messages.Add(msg); - } - else if (content is McpServerToolResultContent mcpResult && - mcpResult.RawRepresentation is ToolCall mcpToolCall && - // TODO: what if there are multiple outputs? - mcpResult.Outputs is { Count: 1 } && - mcpResult.Outputs[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 && - // TODO: what if there are multiple outputs? - 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; - - request.Messages.Add(gmsg); - } - - if (options is GrokChatOptions grokOptions) - { - 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()); - } - - request.UseEncryptedContent = grokOptions.UseEncryptedContent; - } - - if (options?.Tools is not null) - { - foreach (var tool in options.Tools.Select(x => x.AsProtocolTool(options))) - if (tool is not null) request.Tools.Add(tool); - } - - if (options?.ResponseFormat is ChatResponseFormatJson jsonFormat) - { - request.ResponseFormat = new ResponseFormat { FormatType = FormatType.JsonObject }; - if (jsonFormat.Schema != null) - { - request.ResponseFormat.FormatType = FormatType.JsonSchema; - request.ResponseFormat.Schema = jsonFormat.Schema?.ToString(); - } - } - - return request; - } - /// public object? GetService(Type serviceType, object? serviceKey = null) => serviceType switch { diff --git a/src/xAI/GrokProtocolExtensions.cs b/src/xAI/GrokProtocolExtensions.cs index bc2780a..2be359c 100644 --- a/src/xAI/GrokProtocolExtensions.cs +++ b/src/xAI/GrokProtocolExtensions.cs @@ -221,6 +221,195 @@ static IEnumerable ToChatMessages(IEnumerable me } } + /// Converts messages and optional options to an xAI protocol completions request. + internal static GetCompletionsRequest AsCompletionsRequest(this IGrokChatClient client, IEnumerable messages, ChatOptions? options = null) + { + var request = options?.RawRepresentationFactory?.Invoke(client) as GetCompletionsRequest ?? new GetCompletionsRequest() + { + Model = options?.ModelId ?? client.DefaultModelId, + }; + + if (string.IsNullOrEmpty(request.Model)) + request.Model = options?.ModelId ?? client.DefaultModelId; + + if ((options?.EndUserId ?? client.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) + { + if (message.RawRepresentation is Message input) + { + request.Messages.Add(input); + continue; + } + else if (message.RawRepresentation is CompletionMessage completion) + { + request.Messages.Add(completion.AsMessage()); + continue; + } + + var gmsg = new Message { Role = message.Role.Convert() }; + + foreach (var content in message.Contents) + { + if (content.RawRepresentation is CompletionMessage completion) + { + request.Messages.Add(completion.AsMessage()); + continue; + } + if (content.RawRepresentation is Content protoContent) + { + gmsg.Content.Add(protoContent); + continue; + } + + if (content is TextContent textContent && !string.IsNullOrEmpty(textContent.Text)) + { + gmsg.Content.Add(new Content { Text = textContent.Text }); + } + else if (content is TextReasoningContent reasoning) + { + gmsg.ReasoningContent = reasoning.Text; + gmsg.EncryptedContent = reasoning.ProtectedData; + } + else if (content is DataContent dataContent) + { + gmsg.Content.Add(new Content + { + File = new FileContent + { + Data = Google.Protobuf.ByteString.CopyFrom(dataContent.Data.Span), + MimeType = dataContent.MediaType, + Filename = dataContent.Name ?? "", + } + }); + //gmsg.Content.Add(new Content { ImageUrl = new ImageUrlContent { ImageUrl = $"data:{dataContent.MediaType};base64,{System.Convert.ToBase64String(dataContent.Data.Span)}" } }); + } + else if (content is UriContent uriContent) + { + if (uriContent.HasTopLevelMediaType("image")) + { + gmsg.Content.Add(new Content + { + ImageUrl = new ImageUrlContent { ImageUrl = uriContent.Uri.ToString() }, + }); + } + else + { + gmsg.Content.Add(new Content + { + File = new FileContent + { + Url = uriContent.Uri.ToString(), + MimeType = uriContent.MediaType + } + }); + } + } + 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) + { + var msg = new Message + { + Role = MessageRole.RoleTool, + Content = { new Content { Text = JsonSerializer.Serialize(resultContent.Result) ?? "null" } } + }; + + if (resultContent.CallId is { Length: > 0 } callId) + msg.ToolCallId = callId; + + request.Messages.Add(msg); + } + else if (content is McpServerToolResultContent mcpResult && + mcpResult.RawRepresentation is ToolCall mcpToolCall && + // TODO: what if there are multiple outputs? + mcpResult.Outputs is { Count: 1 } && + mcpResult.Outputs[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 && + // TODO: what if there are multiple outputs? + 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; + + request.Messages.Add(gmsg); + } + + if (options is GrokChatOptions grokOptions) + { + 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()); + } + + request.UseEncryptedContent = grokOptions.UseEncryptedContent; + } + + if (options?.Tools is not null) + { + foreach (var tool in options.Tools.Select(x => x.AsProtocolTool(options))) + if (tool is not null) request.Tools.Add(tool); + } + + if (options?.ResponseFormat is ChatResponseFormatJson jsonFormat) + { + request.ResponseFormat = new ResponseFormat { FormatType = FormatType.JsonObject }; + if (jsonFormat.Schema != null) + { + request.ResponseFormat.FormatType = FormatType.JsonSchema; + request.ResponseFormat.Schema = jsonFormat.Schema?.ToString(); + } + } + + return request; + } + + internal static IEnumerable AsContents(this IEnumerable toolCalls, string? content = default, List? annotations = default) { foreach (var toolCall in toolCalls)