Skip to content

Commit e41e0b8

Browse files
committed
Add hosted search tool support
1 parent e515d00 commit e41e0b8

File tree

6 files changed

+190
-39
lines changed

6 files changed

+190
-39
lines changed

src/Extensions.Grok/GrokChatClient.cs

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Text.Json;
2+
using System.Linq;
23
using System.ClientModel.Primitives;
34

45
using Microsoft.Extensions.AI;
@@ -30,15 +31,19 @@ public async Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messag
3031

3132
var protoResponse = await client.GetCompletionAsync(requestDto, cancellationToken: cancellationToken);
3233

33-
// Assuming single choice (n=1)
34-
var output = protoResponse.Outputs[0];
35-
36-
return new ChatResponse(new[] { MapToChatMessage(output.Message) })
34+
var chatMessages = protoResponse.Outputs
35+
.Select(x => MapToChatMessage(x.Message))
36+
.Where(x => x.Contents.Count > 0)
37+
.ToList();
38+
39+
var lastOutput = protoResponse.Outputs.LastOrDefault();
40+
41+
return new ChatResponse(chatMessages)
3742
{
3843
ResponseId = protoResponse.Id,
3944
ModelId = protoResponse.Model,
4045
CreatedAt = protoResponse.Created.ToDateTimeOffset(),
41-
FinishReason = MapFinishReason(output.FinishReason),
46+
FinishReason = lastOutput != null ? MapFinishReason(lastOutput.FinishReason) : null,
4247
Usage = MapToUsage(protoResponse.Usage),
4348
};
4449
}
@@ -110,6 +115,40 @@ GetCompletionsRequest MapToRequest(IEnumerable<ChatMessage> messages, ChatOption
110115
};
111116
request.Tools.Add(new Tool { Function = function });
112117
}
118+
else if (tool is HostedWebSearchTool webSearchTool)
119+
{
120+
if (webSearchTool is GrokXSearchTool xSearch)
121+
{
122+
var toolProto = new XSearch
123+
{
124+
EnableImageUnderstanding = xSearch.EnableImageUnderstanding,
125+
EnableVideoUnderstanding = xSearch.EnableVideoUnderstanding,
126+
};
127+
128+
if (xSearch.AllowedHandles is { } allowed) toolProto.AllowedXHandles.AddRange(allowed);
129+
if (xSearch.ExcludedHandles is { } excluded) toolProto.ExcludedXHandles.AddRange(excluded);
130+
if (xSearch.FromDate is { } from) toolProto.FromDate = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(from.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
131+
if (xSearch.ToDate is { } to) toolProto.ToDate = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(to.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc));
132+
133+
request.Tools.Add(new Tool { XSearch = toolProto });
134+
}
135+
else if (webSearchTool is GrokSearchTool grokSearch)
136+
{
137+
var toolProto = new WebSearch
138+
{
139+
EnableImageUnderstanding = grokSearch.EnableImageUnderstanding,
140+
};
141+
142+
if (grokSearch.AllowedDomains is { } allowed) toolProto.AllowedDomains.AddRange(allowed);
143+
if (grokSearch.ExcludedDomains is { } excluded) toolProto.ExcludedDomains.AddRange(excluded);
144+
145+
request.Tools.Add(new Tool { WebSearch = toolProto });
146+
}
147+
else
148+
{
149+
request.Tools.Add(new Tool { WebSearch = new WebSearch() });
150+
}
151+
}
113152
}
114153
}
115154

@@ -120,10 +159,6 @@ GetCompletionsRequest MapToRequest(IEnumerable<ChatMessage> messages, ChatOption
120159
FormatType = FormatType.JsonObject
121160
};
122161
}
123-
else
124-
{
125-
request.ResponseFormat = new ResponseFormat { FormatType = FormatType.Text };
126-
}
127162

128163
return request;
129164
}
@@ -148,11 +183,18 @@ GetCompletionsRequest MapToRequest(IEnumerable<ChatMessage> messages, ChatOption
148183

149184
static ChatMessage MapToChatMessage(CompletionMessage message)
150185
{
151-
var chatMessage = new ChatMessage(ChatRole.Assistant, message.Content);
186+
var chatMessage = new ChatMessage() { Role = MapRole(message.Role) };
187+
188+
if (!string.IsNullOrEmpty(message.Content))
189+
{
190+
chatMessage.Contents.Add(new TextContent(message.Content) { Annotations = [] });
191+
}
152192

153193
foreach (var toolCall in message.ToolCalls)
154194
{
155-
if (toolCall.ToolCase == XaiApi.ToolCall.ToolOneofCase.Function)
195+
// Only include client-side tools in the response messages
196+
if (toolCall.ToolCase == XaiApi.ToolCall.ToolOneofCase.Function &&
197+
toolCall.Type == ToolCallType.ClientSideTool)
156198
{
157199
var arguments = !string.IsNullOrEmpty(toolCall.Function.Arguments)
158200
? JsonSerializer.Deserialize<IDictionary<string, object?>>(toolCall.Function.Arguments)

src/Extensions.Grok/GrokClient.cs

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,22 @@
1-
using System.ClientModel;
2-
using System.ClientModel.Primitives;
3-
using System.Collections.Concurrent;
4-
using System.Net.Http;
1+
using System.Collections.Concurrent;
52
using System.Net.Http.Headers;
6-
using Grpc.Core;
73
using Grpc.Net.Client;
84

95
namespace Devlooped.Extensions.AI.Grok;
106

11-
public class GrokClient
7+
public class GrokClient(string apiKey, GrokClientOptions options)
128
{
139
static ConcurrentDictionary<(Uri, string), GrpcChannel> channels = [];
1410

1511
public GrokClient(string apiKey) : this(apiKey, new GrokClientOptions()) { }
1612

17-
public GrokClient(string apiKey, GrokClientOptions options)
18-
{
19-
ApiKey = apiKey;
20-
Options = options;
21-
Endpoint = options.Endpoint;
22-
}
23-
24-
public string ApiKey { get; }
13+
public string ApiKey { get; } = apiKey;
2514

2615
/// <summary>Gets or sets the endpoint for the service.</summary>
27-
public Uri Endpoint { get; set; }
16+
public Uri Endpoint { get; set; } = options.Endpoint;
2817

2918
/// <summary>Gets the options used to configure the client.</summary>
30-
public GrokClientOptions Options { get; }
19+
public GrokClientOptions Options { get; } = options;
3120

3221
internal GrpcChannel Channel => channels.GetOrAdd((Endpoint, ApiKey), key =>
3322
{
Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,102 @@
1-
using System.Text.Json.Serialization;
1+
using System.ComponentModel;
2+
using System.Text.Json.Serialization;
23
using Microsoft.Extensions.AI;
34

45
namespace Devlooped.Extensions.AI.Grok;
56

67
/// <summary>
7-
/// Enables or disables Grok's live search capabilities.
8-
/// See https://docs.x.ai/docs/guides/live-search#enabling-search
8+
/// Enables or disables Grok's search tool.
9+
/// See https://docs.x.ai/docs/guides/tools/search-tools
910
/// </summary>
11+
[EditorBrowsable(EditorBrowsableState.Never)]
12+
[Obsolete("No longer supported in agent search too. Use either GrokSearchTool or GrokXSearchTool")]
1013
public enum GrokSearch
1114
{
1215
/// <summary>
13-
/// (default): Live search is available to the model, but the model automatically decides whether to perform live search.
16+
/// (default): Search tool is available to the model, but the model automatically decides whether to perform search.
1417
/// </summary>
1518
Auto,
1619
/// <summary>
17-
/// Enables live search.
20+
/// Enables search tool.
1821
/// </summary>
1922
On,
2023
/// <summary>
21-
/// Disables search and uses the model without accessing additional information from data sources.
24+
/// Disables search tool and uses the model without accessing additional information from data sources.
2225
/// </summary>
2326
Off
2427
}
2528

26-
/// <summary>Configures Grok's live search capabilities. See https://docs.x.ai/docs/guides/live-search.</summary>
27-
public class GrokSearchTool(GrokSearch mode) : HostedWebSearchTool
29+
/// <summary>
30+
/// Configures Grok's agentic search tool.
31+
/// See https://docs.x.ai/docs/guides/tools/search-tools.
32+
/// </summary>
33+
public class GrokSearchTool : HostedWebSearchTool
2834
{
29-
/// <summary>Sets the search mode for Grok's live search capabilities.</summary>
30-
public GrokSearch Mode { get; } = mode;
35+
public GrokSearchTool() { }
36+
3137
/// <inheritdoc/>
32-
public override string Name => "Live Search";
38+
public override string Name => "Search";
3339
/// <inheritdoc/>
34-
public override string Description => "Performs live search using X.AI";
40+
public override string Description => "Performs search using X.AI";
41+
42+
/// <summary>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.</summary>
43+
public IList<string>? AllowedDomains { get; set; }
44+
/// <summary>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.</summary>
45+
public IList<string>? ExcludedDomains { get; set; }
46+
/// <summary>See https://docs.x.ai/docs/guides/tools/search-tools#enable-image-understanding</summary>
47+
public bool EnableImageUnderstanding { get; set; }
48+
49+
#region Legacy Live Search
50+
[EditorBrowsable(EditorBrowsableState.Never)]
51+
[Obsolete("Legacy live search mode is not available in new agentic search tool.")]
52+
public GrokSearchTool(GrokSearch mode) => Mode = mode;
53+
/// <summary>Sets the search mode for Grok's live search capabilities.</summary>
54+
[EditorBrowsable(EditorBrowsableState.Never)]
55+
[Obsolete("Legacy live search mode is not available in new agentic search tool.")]
56+
public GrokSearch Mode { get; set; }
3557
/// <summary>See https://docs.x.ai/docs/guides/live-search#set-date-range-of-the-search-data</summary>
58+
[EditorBrowsable(EditorBrowsableState.Never)]
59+
[Obsolete("Date range can only be applied to X source.")]
3660
public DateOnly? FromDate { get; set; }
3761
/// <summary>See https://docs.x.ai/docs/guides/live-search#set-date-range-of-the-search-data</summary>
62+
[EditorBrowsable(EditorBrowsableState.Never)]
63+
[Obsolete("Date range can only be applied to X search tool.")]
3864
public DateOnly? ToDate { get; set; }
3965
/// <summary>See https://docs.x.ai/docs/guides/live-search#limit-the-maximum-amount-of-data-sources</summary>
66+
[EditorBrowsable(EditorBrowsableState.Never)]
67+
[Obsolete("No longer supported in search tool.")]
4068
public int? MaxSearchResults { get; set; }
4169
/// <summary>See https://docs.x.ai/docs/guides/live-search#data-sources-and-parameters</summary>
70+
[EditorBrowsable(EditorBrowsableState.Never)]
71+
[Obsolete("No longer supported in search tool.")]
4272
public IList<GrokSource>? Sources { get; set; }
4373
/// <summary>See https://docs.x.ai/docs/guides/live-search#returning-citations</summary>
74+
[EditorBrowsable(EditorBrowsableState.Never)]
75+
[Obsolete("No longer supported in search tool.")]
4476
public bool? ReturnCitations { get; set; }
77+
#endregion
78+
}
79+
80+
/// <summary>
81+
/// Configures Grok's agentic search tool for X.
82+
/// See https://docs.x.ai/docs/guides/tools/search-tools.
83+
/// </summary>
84+
public class GrokXSearchTool : HostedWebSearchTool
85+
{
86+
/// <summary>See https://docs.x.ai/docs/guides/tools/search-tools#only-consider-x-posts-from-specific-handles</summary>
87+
[JsonPropertyName("allowed_x_handles")]
88+
public IList<string>? AllowedHandles { get; set; }
89+
/// <summary>See https://docs.x.ai/docs/guides/tools/search-tools#exclude-x-posts-from-specific-handles</summary>
90+
[JsonPropertyName("excluded_x_handles")]
91+
public IList<string>? ExcludedHandles { get; set; }
92+
/// <summary>See https://docs.x.ai/docs/guides/tools/search-tools#date-range</summary>
93+
public DateOnly? FromDate { get; set; }
94+
/// <summary>See https://docs.x.ai/docs/guides/tools/search-tools#date-range</summary>
95+
public DateOnly? ToDate { get; set; }
96+
/// <summary>See https://docs.x.ai/docs/guides/tools/search-tools#enable-image-understanding-1</summary>
97+
public bool EnableImageUnderstanding { get; set; }
98+
/// <summary>See https://docs.x.ai/docs/guides/tools/search-tools#enable-video-understanding</summary>
99+
public bool EnableVideoUnderstanding { get; set; }
45100
}
46101

47102
/// <summary>Grok Live Search data source base type.</summary>
@@ -50,9 +105,13 @@ public class GrokSearchTool(GrokSearch mode) : HostedWebSearchTool
50105
[JsonDerivedType(typeof(GrokNewsSource), "news")]
51106
[JsonDerivedType(typeof(GrokRssSource), "rss")]
52107
[JsonDerivedType(typeof(GrokXSource), "x")]
108+
[EditorBrowsable(EditorBrowsableState.Never)]
109+
[Obsolete("No longer supported in agent search too. Use either GrokSearchTool or GrokXSearchTool")]
53110
public abstract class GrokSource { }
54111

55112
/// <summary>Search-based data source base class providing common properties for `web` and `news` sources.</summary>
113+
[EditorBrowsable(EditorBrowsableState.Never)]
114+
[Obsolete("No longer supported in agent search too. Use either GrokSearchTool or GrokXSearchTool")]
56115
public abstract class GrokSearchSource : GrokSource
57116
{
58117
/// <summary>Include data from a specific country/region by specifying the ISO alpha-2 code of the country.</summary>
@@ -64,24 +123,32 @@ public abstract class GrokSearchSource : GrokSource
64123
}
65124

66125
/// <summary>Web live search source.</summary>
126+
[EditorBrowsable(EditorBrowsableState.Never)]
127+
[Obsolete("No longer supported in agent search too. Use either GrokSearchTool or GrokXSearchTool")]
67128
public class GrokWebSource : GrokSearchSource
68129
{
69130
/// <summary>See https://docs.x.ai/docs/guides/live-search#parameter-allowed_websites-supported-by-web</summary>
70131
public IList<string>? AllowedWebsites { get; set; }
71132
}
72133

73134
/// <summary>News live search source.</summary>
135+
[EditorBrowsable(EditorBrowsableState.Never)]
136+
[Obsolete("No longer supported in agent search too. Use either GrokSearchTool or GrokXSearchTool")]
74137
public class GrokNewsSource : GrokSearchSource { }
75138

76139
/// <summary>RSS live search source.</summary>
77140
/// <param name="rss">The RSS feed to search.</param>
141+
[EditorBrowsable(EditorBrowsableState.Never)]
142+
[Obsolete("No longer supported in agent search too. Use either GrokSearchTool or GrokXSearchTool")]
78143
public class GrokRssSource(string rss) : GrokSource
79144
{
80145
/// <summary>See https://docs.x.ai/docs/guides/live-search#parameter-link-supported-by-rss</summary>
81146
public IList<string>? Links { get; set; } = [rss];
82147
}
83148

84149
/// <summary>X live search source./summary>
150+
[EditorBrowsable(EditorBrowsableState.Never)]
151+
[Obsolete("No longer supported in agent search too. Use GrokXSearchTool")]
85152
public class GrokXSource : GrokSearchSource
86153
{
87154
/// <summary>See https://docs.x.ai/docs/guides/live-search#parameter-excluded_x_handles-supported-by-x</summary>
@@ -96,4 +163,9 @@ public class GrokXSource : GrokSearchSource
96163
/// <summary>See https://docs.x.ai/docs/guides/live-search#parameters-post_favorite_count-and-post_view_count-supported-by-x</summary>
97164
[JsonPropertyName("post_view_count")]
98165
public int? ViewCount { get; set; }
166+
[JsonPropertyName("from_date")]
167+
public DateOnly? FromDate { get; set; }
168+
/// <summary>See https://docs.x.ai/docs/guides/live-search#set-date-range-of-the-search-data</summary>
169+
[JsonPropertyName("to_date")]
170+
public DateOnly? ToDate { get; set; }
99171
}

src/Tests/GrokTests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,4 +302,52 @@ public async Task CanAvoidCitations()
302302
Assert.NotNull(node);
303303
Assert.Null(node["citations"]);
304304
}
305+
306+
[SecretsFact("XAI_API_KEY")]
307+
public async Task GrokGrpcInvokesHostedSearchTool()
308+
{
309+
var messages = new Chat()
310+
{
311+
{ "system", "You are an AI assistant that knows how to search the web." },
312+
{ "user", "What's Tesla stock worth today? Search X and the news for latest info." },
313+
};
314+
315+
var grok = new GrokClient(Configuration["XAI_API_KEY"]!).AsIChatClient("grok-4-fast");
316+
317+
var options = new ChatOptions
318+
{
319+
Tools = [new HostedWebSearchTool()]
320+
};
321+
322+
var response = await grok.GetResponseAsync(messages, options);
323+
var text = response.Text;
324+
325+
Assert.Contains("TSLA", text);
326+
Assert.NotNull(response.ModelId);
327+
}
328+
329+
[SecretsFact("XAI_API_KEY")]
330+
public async Task GrokGrpcInvokesGrokSearchTool()
331+
{
332+
var messages = new Chat()
333+
{
334+
{ "system", "You are an AI assistant that knows how to search the web." },
335+
{ "user", "What is the latest news about Microsoft?" },
336+
};
337+
338+
var grok = new GrokClient(Configuration["XAI_API_KEY"]!).AsIChatClient("grok-4-fast");
339+
340+
var options = new ChatOptions
341+
{
342+
Tools = [new GrokSearchTool
343+
{
344+
AllowedDomains = ["microsoft.com", "news.microsoft.com"]
345+
}]
346+
};
347+
348+
var response = await grok.GetResponseAsync(messages, options);
349+
350+
Assert.NotNull(response.Text);
351+
Assert.Contains("Microsoft", response.Text);
352+
}
305353
}

0 commit comments

Comments
 (0)