11using System . ClientModel ;
22using System . ClientModel . Primitives ;
33using System . Collections . Concurrent ;
4+ using System . Diagnostics ;
45using System . Text . Json ;
6+ using System . Text . Json . Serialization ;
57using Microsoft . Extensions . AI ;
68using OpenAI ;
79
@@ -10,7 +12,7 @@ namespace Devlooped.Extensions.AI;
1012/// <summary>
1113/// An <see cref="IChatClient"/> implementation for Grok.
1214/// </summary>
13- public class GrokChatClient : IChatClient
15+ public partial class GrokChatClient : IChatClient
1416{
1517 readonly ConcurrentDictionary < string , IChatClient > clients = new ( ) ;
1618 readonly string modelId ;
@@ -52,21 +54,39 @@ IChatClient GetChatClient(string modelId) => clients.GetOrAdd(modelId, model
5254 var result = new GrokCompletionOptions ( ) ;
5355 var grok = options as GrokChatOptions ;
5456 var search = grok ? . Search ;
57+ var tool = options . Tools ? . OfType < GrokSearchTool > ( ) . FirstOrDefault ( ) ;
58+ GrokChatWebSearchOptions ? searchOptions = default ;
5559
56- if ( options . Tools != null )
60+ if ( search is not null && tool is null )
5761 {
58- if ( options . Tools . OfType < GrokSearchTool > ( ) . FirstOrDefault ( ) is GrokSearchTool grokSearch )
59- search = grokSearch . Mode ;
60- else if ( options . Tools . OfType < HostedWebSearchTool > ( ) . FirstOrDefault ( ) is HostedWebSearchTool webSearch )
61- search = GrokSearch . Auto ;
62-
63- // Grok doesn't support any other hosted search tools, so remove remaining ones
64- // so they don't get copied over by the OpenAI client.
65- //options.Tools = [.. options.Tools.Where(tool => tool is not HostedWebSearchTool)];
62+ searchOptions = new GrokChatWebSearchOptions
63+ {
64+ Mode = search . Value
65+ } ;
66+ }
67+ else if ( tool is null && options . Tools ? . OfType < HostedWebSearchTool > ( ) . FirstOrDefault ( ) is not null )
68+ {
69+ searchOptions = new GrokChatWebSearchOptions
70+ {
71+ Mode = GrokSearch . Auto
72+ } ;
73+ }
74+ else if ( tool is not null )
75+ {
76+ searchOptions = new GrokChatWebSearchOptions
77+ {
78+ Mode = tool . Mode ,
79+ FromDate = tool . FromDate ,
80+ ToDate = tool . ToDate ,
81+ MaxSearchResults = tool . MaxSearchResults ,
82+ Sources = tool . Sources
83+ } ;
6684 }
6785
68- if ( search != null )
69- result . Search = search . Value ;
86+ if ( searchOptions is not null )
87+ {
88+ result . WebSearchOptions = searchOptions ;
89+ }
7090
7191 if ( grok ? . ReasoningEffort != null )
7292 {
@@ -91,19 +111,76 @@ void IDisposable.Dispose() { }
91111 // Allows creating the base OpenAIClient with a pre-created pipeline.
92112 class PipelineClient ( ClientPipeline pipeline , OpenAIClientOptions options ) : OpenAIClient ( pipeline , options ) { }
93113
94- class GrokCompletionOptions : OpenAI . Chat . ChatCompletionOptions
114+ class GrokChatWebSearchOptions : OpenAI . Chat . ChatWebSearchOptions
115+ {
116+ public GrokSearch Mode { get ; set ; } = GrokSearch . Auto ;
117+ public DateOnly ? FromDate { get ; set ; }
118+ public DateOnly ? ToDate { get ; set ; }
119+ public int ? MaxSearchResults { get ; set ; }
120+ public IList < GrokSource > ? Sources { get ; set ; }
121+ }
122+
123+ [ JsonSourceGenerationOptions ( JsonSerializerDefaults . Web ,
124+ DefaultIgnoreCondition = JsonIgnoreCondition . WhenWritingNull | JsonIgnoreCondition . WhenWritingDefault ,
125+ UnmappedMemberHandling = JsonUnmappedMemberHandling . Skip ,
126+ PropertyNameCaseInsensitive = true ,
127+ PropertyNamingPolicy = JsonKnownNamingPolicy . SnakeCaseLower
128+ #if DEBUG
129+ , WriteIndented = true
130+ #endif
131+ ) ]
132+ [ JsonSerializable ( typeof ( GrokChatWebSearchOptions ) ) ]
133+ [ JsonSerializable ( typeof ( GrokSearch ) ) ]
134+ [ JsonSerializable ( typeof ( GrokSource ) ) ]
135+ [ JsonSerializable ( typeof ( GrokRssSource ) ) ]
136+ [ JsonSerializable ( typeof ( GrokWebSource ) ) ]
137+ [ JsonSerializable ( typeof ( GrokNewsSource ) ) ]
138+ [ JsonSerializable ( typeof ( GrokXSource ) ) ]
139+ partial class GrokJsonContext : JsonSerializerContext
95140 {
96- public GrokSearch Search { get ; set ; } = GrokSearch . Auto ;
141+ static readonly Lazy < JsonSerializerOptions > options = new ( CreateDefaultOptions ) ;
97142
143+ /// <summary>
144+ /// Provides a pre-configured instance of <see cref="JsonSerializerOptions"/> that aligns with the context's settings.
145+ /// </summary>
146+ public static JsonSerializerOptions DefaultOptions { get => options . Value ; }
147+
148+ static JsonSerializerOptions CreateDefaultOptions ( )
149+ {
150+ JsonSerializerOptions options = new ( Default . Options )
151+ {
152+ WriteIndented = Debugger . IsAttached ,
153+ Converters =
154+ {
155+ new JsonStringEnumConverter ( new LowercaseNamingPolicy ( ) ) ,
156+ } ,
157+ } ;
158+
159+ options . MakeReadOnly ( ) ;
160+ return options ;
161+ }
162+
163+ class LowercaseNamingPolicy : JsonNamingPolicy
164+ {
165+ public override string ConvertName ( string name ) => name . ToLowerInvariant ( ) ;
166+ }
167+ }
168+
169+ class GrokCompletionOptions : OpenAI . Chat . ChatCompletionOptions
170+ {
98171 protected override void JsonModelWriteCore ( Utf8JsonWriter writer , ModelReaderWriterOptions ? options )
99172 {
173+ var search = WebSearchOptions as GrokChatWebSearchOptions ;
174+ // This avoids writing the default `web_search_options` property
175+ WebSearchOptions = null ;
176+
100177 base . JsonModelWriteCore ( writer , options ) ;
101178
102- // "search_parameters": { "mode": "auto" }
103- writer . WritePropertyName ( "search_parameters" ) ;
104- writer . WriteStartObject ( ) ;
105- writer . WriteString ( "mode" , Search . ToString ( ) . ToLowerInvariant ( ) ) ;
106- writer . WriteEndObject ( ) ;
179+ if ( search != null )
180+ {
181+ writer . WritePropertyName ( "search_parameters" ) ;
182+ JsonSerializer . Serialize ( writer , search , GrokJsonContext . DefaultOptions ) ;
183+ }
107184 }
108185 }
109186}
0 commit comments